Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.85% covered (warning)
84.85%
56 / 66
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_Proxy_Request
84.85% covered (warning)
84.85%
56 / 66
25.00% covered (danger)
25.00%
1 / 4
24.84
0.00% covered (danger)
0.00%
0 / 1
 proxy_request_to_wpcom
86.21% covered (warning)
86.21%
50 / 58
0.00% covered (danger)
0.00%
0 / 1
17.76
 proxy_request_to_wpcom_as_user
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 proxy_request_to_wpcom_as_blog
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 drop_unsafe_query_keys
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * Trait WPCOM_REST_API_Proxy_Request
4 *
5 * Used to proxy requests to wpcom servers.
6 *
7 * @package automattic/jetpack-connection
8 */
9
10namespace Automattic\Jetpack\Connection\Traits;
11
12use Automattic\Jetpack\Connection\Client;
13use Automattic\Jetpack\Connection\Manager;
14use Automattic\Jetpack\Status\Visitor;
15use WP_Error;
16use WP_REST_Request;
17
18trait WPCOM_REST_API_Proxy_Request {
19
20    /**
21     * Base path for the API.
22     *
23     * @var string
24     */
25    protected $base_api_path;
26
27    /**
28     * Version of the API.
29     *
30     * @var string
31     */
32    protected $version;
33
34    /**
35     * The base of the controller's route.
36     *
37     * @var string
38     */
39    protected $rest_base;
40
41    /**
42     * Proxy request to wpcom servers on behalf of a user or using the Site-level Connection (blog token).
43     *
44     * @param WP_REST_Request $request Request to proxy.
45     * @param string          $path Path to append to the rest base.
46     * @param string          $context Whether the request should be proxied on behalf of the current user or using the Site-level Connection, aka 'blog' token. Can be Either 'user' or 'blog'. Defaults to 'user'.
47     * @param bool            $allow_fallback_to_blog If the $context is 'user', whether we should fallback to using the Site-level Connection in case the current user is not connected.
48     * @param array           $request_options Request options to pass to wp_remote_request.
49     *
50     * @return mixed|WP_Error           Response from wpcom servers or an error.
51     */
52    public function proxy_request_to_wpcom( $request, $path = '', $context = 'user', $allow_fallback_to_blog = false, $request_options = array() ) {
53        $site_path = ltrim( $this->rest_base, '/' ) . ( $path ? '/' . ltrim( (string) $path, '/' ) : '' );
54
55        // Route captures arrive already decoded, so any of these would change which resource the outgoing URL names.
56        if ( preg_match( '#[?\#%\s]|(^|/)\.\.?(/|$)#', $site_path ) ) {
57            return new WP_Error(
58                'rest_invalid_param',
59                __( 'Invalid request path.', 'jetpack-connection' ),
60                array( 'status' => 400 )
61            );
62        }
63
64        $blog_id      = \Jetpack_Options::get_option( 'id' );
65        $path         = '/sites/' . rawurldecode( $blog_id ) . '/' . $site_path;
66        $query_params = $request->get_query_params();
67        $manager      = new Manager();
68
69        /*
70         * A rest_route parameter can be added when using plain permalinks.
71         * It is not necessary to pass them to WordPress.com,
72         * and may even cause issues with some endpoints.
73         * Let's remove it.
74         */
75        if ( isset( $query_params['rest_route'] ) ) {
76            unset( $query_params['rest_route'] );
77        }
78        unset( $query_params['_method'] );
79        $query_params = self::drop_unsafe_query_keys( $query_params );
80        $api_url      = $query_params ? $path . '?' . http_build_query( $query_params, '', '&' ) : $path;
81
82        $request_options = array_replace_recursive(
83            array(
84                'headers' => array(
85                    'Content-Type'    => 'application/json',
86                    'X-Forwarded-For' => ( new Visitor() )->get_ip( true ),
87                ),
88                'method'  => $request->get_method(),
89            ),
90            $request_options
91        );
92
93        // If no body is present, passing it as $request->get_body() will cause an error.
94        $body = $request->get_body() ? $request->get_body() : null;
95
96        // The body goes out as JSON, so WordPress.com must read it the way this site did.
97        // A request built in code has no route, and older callers build one with an unlabelled JSON body.
98        $is_unlabelled_internal = '' === $request->get_route() && null === $request->get_content_type();
99        if ( null !== $body && ! $request->is_json_content_type() && ! $is_unlabelled_internal ) {
100            return new WP_Error(
101                'rest_invalid_content_type',
102                __( 'This endpoint only accepts a JSON request body.', 'jetpack-connection' ),
103                array( 'status' => 415 )
104            );
105        }
106
107        $response = new WP_Error(
108            'rest_unauthorized',
109            __( 'Please connect your user account to WordPress.com', 'jetpack-connection' ),
110            array( 'status' => rest_authorization_required_code() )
111        );
112
113        if ( 'user' === $context ) {
114            if ( ! $manager->is_user_connected() ) {
115                if ( false === $allow_fallback_to_blog ) {
116                    return $response;
117                }
118
119                $context = 'blog';
120            } else {
121                $response = Client::wpcom_json_api_request_as_user( $api_url, $this->version, $request_options, $body, $this->base_api_path );
122            }
123        }
124
125        if ( 'blog' === $context ) {
126            if ( ! $manager->is_connected() ) {
127                return $response;
128            }
129
130            $response = Client::wpcom_json_api_request_as_blog( $api_url, $this->version, $request_options, $body, $this->base_api_path );
131        }
132
133        if ( is_wp_error( $response ) ) {
134            return $response;
135        }
136
137        $response_status = wp_remote_retrieve_response_code( $response );
138        $response_body   = json_decode( wp_remote_retrieve_body( $response ), true );
139
140        if ( $response_status >= 400 ) {
141            $code    = $response_body['code'] ?? 'unknown_error';
142            $message = $response_body['message'] ?? __( 'An unknown error occurred.', 'jetpack-connection' );
143
144            return new WP_Error( $code, $message, array( 'status' => $response_status ) );
145        }
146
147        return $response_body;
148    }
149
150    /**
151     * Proxy request to wpcom servers on behalf of a user.
152     *
153     * @param WP_REST_Request $request Request to proxy.
154     * @param string          $path Path to append to the rest base.
155     * @param array           $request_options Request options to pass to wp_remote_request.
156     *
157     * @return mixed|WP_Error           Response from wpcom servers or an error.
158     */
159    public function proxy_request_to_wpcom_as_user( $request, $path = '', $request_options = array() ) {
160        return $this->proxy_request_to_wpcom( $request, $path, 'user', false, $request_options );
161    }
162
163    /**
164     * Proxy request to wpcom servers using the Site-level Connection (blog token).
165     *
166     * @param WP_REST_Request $request Request to proxy.
167     * @param string          $path Path to append to the rest base.
168     * @param array           $request_options Request options to pass to wp_remote_request.
169     *
170     * @return mixed|WP_Error           Response from wpcom servers or an error.
171     */
172    public function proxy_request_to_wpcom_as_blog( $request, $path = '', $request_options = array() ) {
173        return $this->proxy_request_to_wpcom( $request, $path, 'blog', false, $request_options );
174    }
175
176    /**
177     * Drop, at every depth, the params whose key is not made of letters, digits, `_` and `-`.
178     *
179     * Any other key can turn into a different key if code on the way to WordPress.com decodes the query again, as `add_query_arg()` does.
180     *
181     * @param array $params Query params.
182     * @return array
183     */
184    private static function drop_unsafe_query_keys( array $params ): array {
185        $safe = array();
186
187        foreach ( $params as $key => $value ) {
188            if ( ! preg_match( '/^[A-Za-z0-9_-]+$/', (string) $key ) ) {
189                continue;
190            }
191
192            $safe[ $key ] = is_array( $value ) ? self::drop_unsafe_query_keys( $value ) : $value;
193        }
194
195        return $safe;
196    }
197}