Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.07% covered (danger)
9.07%
43 / 474
12.20% covered (danger)
12.20%
5 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_JSON_API
9.13% covered (danger)
9.13%
43 / 471
12.20% covered (danger)
12.20%
5 / 41
31738.80
0.00% covered (danger)
0.00%
0 / 1
 init
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 add
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 is_truthy
41.67% covered (danger)
41.67%
5 / 12
0.00% covered (danger)
0.00%
0 / 1
16.73
 is_falsy
25.00% covered (danger)
25.00%
3 / 12
0.00% covered (danger)
0.00%
0 / 1
27.67
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setup_inputs
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
380
 initialize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 is_jetpack_authorized_for_site
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 is_authorized_with_upload_token
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 serve
0.00% covered (danger)
0.00%
0 / 101
0.00% covered (danger)
0.00%
0 / 1
1640
 process_request
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 output_early
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 set_output_status_code
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 output
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
342
 wrap_http_envelope
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 serializable_error
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 output_error
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 filter_fields
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
380
 ensure_http_scheme_of_home_url
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 comment_edit_pre
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 json_encode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ends_with
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_blog_id_for_output
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_blog_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 switch_to_blog_and_validate_user
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 maybe_switch_to_token_user_and_site
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
33.84
 is_restricted_blog
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 post_like_count
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_liked
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_reblogged
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_following
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 add_global_ID
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 comment_like_count
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 get_avatar_url
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 wp_count_comments
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
110
 trap_wp_die
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
132
 wp_die_handler_callback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wp_die_handler
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 output_trapped_error
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 finish_request
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 init_locale
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
1<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2/**
3 * Jetpack JSON API.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Status;
9
10if ( ! defined( 'WPCOM_JSON_API__DEBUG' ) ) {
11    define( 'WPCOM_JSON_API__DEBUG', false );
12}
13
14require_once __DIR__ . '/sal/class.json-api-platform.php';
15
16/**
17 * Jetpack JSON API.
18 */
19class WPCOM_JSON_API {
20    /**
21     * Static instance.
22     *
23     * @todo This should be private.
24     * @var self|null
25     */
26    public static $self = null;
27
28    /**
29     * Registered endpoints.
30     *
31     * @var WPCOM_JSON_API_Endpoint[]
32     */
33    public $endpoints = array();
34
35    /**
36     * Endpoint being processed.
37     *
38     * @var WPCOM_JSON_API_Endpoint
39     */
40    public $endpoint = null;
41
42    /**
43     * Token details.
44     *
45     * @var array
46     */
47    public $token_details = array();
48
49    /**
50     * Request HTTP method.
51     *
52     * @var string
53     */
54    public $method = '';
55
56    /**
57     * Request URL.
58     *
59     * @var string
60     */
61    public $url = '';
62
63    /**
64     * Path part of the request URL.
65     *
66     * @var string
67     */
68    public $path = '';
69
70    /**
71     * Version extracted from the request URL.
72     *
73     * @var string|null
74     */
75    public $version = null;
76
77    /**
78     * Parsed query data.
79     *
80     * @var array
81     */
82    public $query = array();
83
84    /**
85     * Post body, if the request is a POST.
86     *
87     * @var string|null
88     */
89    public $post_body = null;
90
91    /**
92     * Copy of `$_FILES` if the request is a POST.
93     *
94     * @var null|array
95     */
96    public $files = null;
97
98    /**
99     * Content type of the request.
100     *
101     * @var string|null
102     */
103    public $content_type = null;
104
105    /**
106     * Value of `$_SERVER['HTTP_ACCEPT']`, if any
107     *
108     * @var string
109     */
110    public $accept = '';
111
112    /**
113     * Value of `$_SERVER['HTTPS']`, or "--UNset--" if unset.
114     *
115     * @var string
116     */
117    public $_server_https; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore
118
119    /**
120     * Whether to exit after serving a response.
121     *
122     * @var bool
123     */
124    public $exit = true;
125
126    /**
127     * Public API scheme.
128     *
129     * @var string
130     */
131    public $public_api_scheme = 'https';
132
133    /**
134     * Output status code.
135     *
136     * @var int
137     */
138    public $output_status_code = 200;
139
140    /**
141     * Trapped error.
142     *
143     * @var null|array
144     */
145    public $trapped_error = null;
146
147    /**
148     * Whether output has been done.
149     *
150     * @var bool
151     */
152    public $did_output = false;
153
154    /**
155     * Extra HTTP headers.
156     *
157     * @var string
158     */
159    public $extra_headers = array();
160
161    /**
162     * AMP source origin.
163     *
164     * @var string
165     */
166    public $amp_source_origin = null;
167
168    /**
169     * Initialize.
170     *
171     * @param string|null $method As for `$this->setup_inputs()`.
172     * @param string|null $url As for `$this->setup_inputs()`.
173     * @param string|null $post_body As for `$this->setup_inputs()`.
174     * @return WPCOM_JSON_API instance
175     */
176    public static function init( $method = null, $url = null, $post_body = null ) {
177        if ( ! self::$self ) {
178            self::$self = new static( $method, $url, $post_body );
179        }
180        return self::$self;
181    }
182
183    /**
184     * Add an endpoint.
185     *
186     * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint to add.
187     */
188    public function add( WPCOM_JSON_API_Endpoint $endpoint ) {
189        // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
190        // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Legacy, possibly depended on elsewhere.
191        $path_versions = serialize(
192            array(
193                $endpoint->path,
194                $endpoint->min_version,
195                $endpoint->max_version,
196            )
197        );
198        if ( ! isset( $this->endpoints[ $path_versions ] ) ) {
199            $this->endpoints[ $path_versions ] = array();
200        }
201        $this->endpoints[ $path_versions ][ $endpoint->method ] = $endpoint;
202    }
203
204    /**
205     * Determine if a string is truthy. If it's not a string, which can happen with
206     * not well-formed data coming from Jetpack sites, we still consider it a truthy value.
207     *
208     * @param mixed $value true, 1, "1", "t", and "true" (case insensitive) are truthy, everything else isn't.
209     * @return bool
210     */
211    public static function is_truthy( $value ) {
212        if ( true === $value ) {
213            return true;
214        }
215
216        if ( 1 === $value ) {
217            return true;
218        }
219
220        if ( ! is_string( $value ) ) {
221            return false;
222        }
223
224        switch ( strtolower( $value ) ) {
225            case '1':
226            case 't':
227            case 'true':
228                return true;
229        }
230
231        return false;
232    }
233
234    /**
235     * Determine if a string is falsey.
236     *
237     * @param mixed $value false, 0, "0", "f", and "false" (case insensitive) are falsey, everything else isn't.
238     * @return bool
239     */
240    public static function is_falsy( $value ) {
241        if ( false === $value ) {
242            return true;
243        }
244
245        if ( 0 === $value ) {
246            return true;
247        }
248
249        if ( ! is_string( $value ) ) {
250            return false;
251        }
252
253        switch ( strtolower( $value ) ) {
254            case '0':
255            case 'f':
256            case 'false':
257                return true;
258        }
259
260        return false;
261    }
262
263    /**
264     * Constructor.
265     *
266     * @todo This should be private.
267     * @param string|null $method As for `$this->setup_inputs()`.
268     * @param string|null $url As for `$this->setup_inputs()`.
269     * @param string|null $post_body As for `$this->setup_inputs()`.
270     */
271    public function __construct( $method = null, $url = null, $post_body = null ) {
272        $this->setup_inputs( $method, $url, $post_body );
273    }
274
275    /**
276     * Setup inputs.
277     *
278     * @param string|null $method Request HTTP method. Fetched from `$_SERVER` if null.
279     * @param string|null $url URL requested. Determined from `$_SERVER` if null.
280     * @param string|null $post_body POST body. Read from `php://input` if null and method is POST.
281     */
282    public function setup_inputs( $method = null, $url = null, $post_body = null ) {
283        if ( $method === null ) {
284            $this->method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( filter_var( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : '';
285        } else {
286            $this->method = strtoupper( $method );
287        }
288        if ( $url === null ) {
289            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sniff misses the esc_url_raw.
290            $this->url = esc_url_raw( set_url_scheme( 'http://' . ( isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '' ) ) );
291        } else {
292            $this->url = $url;
293        }
294
295        $parsed = wp_parse_url( $this->url );
296        if ( ! empty( $parsed['path'] ) ) {
297            $this->path = $parsed['path'];
298        }
299
300        if ( ! empty( $parsed['query'] ) ) {
301            wp_parse_str( $parsed['query'], $this->query );
302        }
303
304        if ( ! empty( $_SERVER['HTTP_ACCEPT'] ) ) {
305            $this->accept = filter_var( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) );
306        }
307
308        if ( 'POST' === $this->method ) {
309            if ( $post_body === null ) {
310                $this->post_body = file_get_contents( 'php://input' );
311
312                if ( ! empty( $_SERVER['HTTP_CONTENT_TYPE'] ) ) {
313                    $this->content_type = filter_var( wp_unslash( $_SERVER['HTTP_CONTENT_TYPE'] ) );
314                } elseif ( ! empty( $_SERVER['CONTENT_TYPE'] ) ) {
315                    $this->content_type = filter_var( wp_unslash( $_SERVER['CONTENT_TYPE'] ) );
316                } elseif ( isset( $this->post_body[0] ) && '{' === $this->post_body[0] ) {
317                    $this->content_type = 'application/json';
318                } else {
319                    $this->content_type = 'application/x-www-form-urlencoded';
320                }
321
322                if ( str_starts_with( strtolower( $this->content_type ), 'multipart/' ) ) {
323                    // phpcs:ignore WordPress.Security.NonceVerification.Missing
324                    $this->post_body    = http_build_query( stripslashes_deep( $_POST ) );
325                    $this->files        = $_FILES;
326                    $this->content_type = 'multipart/form-data';
327                }
328            } else {
329                $this->post_body    = $post_body;
330                $this->content_type = isset( $this->post_body[0] ) && '{' === $this->post_body[0] ? 'application/json' : 'application/x-www-form-urlencoded';
331            }
332        } else {
333            $this->post_body    = null;
334            $this->content_type = null;
335        }
336
337        $this->_server_https = array_key_exists( 'HTTPS', $_SERVER ) ? filter_var( wp_unslash( $_SERVER['HTTPS'] ) ) : '--UNset--';
338    }
339
340    /**
341     * Initialize.
342     *
343     * @return null|WP_Error (although this implementation always returns null)
344     */
345    public function initialize() {
346        $this->token_details['blog_id'] = Jetpack_Options::get_option( 'id' );
347        return null;
348    }
349
350    /**
351     * Checks if the current request is authorized with a blog token.
352     * This method is overridden by a child class in WPCOM.
353     *
354     * @since 9.1.0
355     *
356     * @param  boolean|int $site_id The site id.
357     * @return boolean
358     */
359    public function is_jetpack_authorized_for_site( $site_id = false ) {
360        if ( ! $this->token_details ) {
361            return false;
362        }
363
364        $token_details = (object) $this->token_details;
365
366        $site_in_token = (int) $token_details->blog_id;
367
368        if ( $site_in_token < 1 ) {
369            return false;
370        }
371
372        if ( $site_id && $site_in_token !== (int) $site_id ) {
373            return false;
374        }
375
376        if ( (int) get_current_user_id() !== 0 ) {
377            // If Jetpack blog token is used, no logged-in user should exist.
378            return false;
379        }
380
381        return true;
382    }
383
384    /**
385     * Checks if the current request is authorized with an upload token.
386     * This method is overridden by a child class in WPCOM.
387     *
388     * @since 13.5
389     * @return boolean
390     */
391    public function is_authorized_with_upload_token() {
392        return false;
393    }
394
395    /**
396     * Serve.
397     *
398     * @param bool $exit Whether to exit.
399     * @return string|null Content type (assuming it didn't exit), or null in certain error cases.
400     */
401    public function serve( $exit = true ) {
402        ini_set( 'display_errors', false ); // phpcs:ignore WordPress.PHP.IniSet.display_errors_Blacklisted
403
404        $this->exit = (bool) $exit;
405
406        // This was causing problems with Jetpack, but is necessary for wpcom
407        // @see https://github.com/Automattic/jetpack/pull/2603
408        // @see r124548-wpcom .
409        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
410            add_filter( 'home_url', array( $this, 'ensure_http_scheme_of_home_url' ), 10, 3 );
411        }
412
413        add_filter( 'user_can_richedit', '__return_true' );
414
415        add_filter( 'comment_edit_pre', array( $this, 'comment_edit_pre' ) );
416
417        $initialization = $this->initialize();
418        if ( 'OPTIONS' === $this->method ) {
419            /**
420             * Fires before the page output.
421             * Can be used to specify custom header options.
422             *
423             * @module json-api
424             *
425             * @since 3.1.0
426             */
427            do_action( 'wpcom_json_api_options' );
428            return $this->output( 200, '', 'text/plain' );
429        }
430
431        if ( is_wp_error( $initialization ) ) {
432            $this->output_error( $initialization );
433            return;
434        }
435
436        // Normalize path and extract API version.
437        $this->path = untrailingslashit( $this->path );
438        if ( preg_match( '#^/rest/v(\d+(\.\d+)*)#', $this->path, $matches ) ) {
439            $this->path    = substr( $this->path, strlen( $matches[0] ) );
440            $this->version = $matches[1];
441        }
442
443        $allowed_methods = array( 'GET', 'POST' );
444        $four_oh_five    = false;
445
446        $is_help            = preg_match( '#/help/?$#i', $this->path );
447        $matching_endpoints = array();
448
449        if ( $is_help ) {
450            $origin = get_http_origin();
451
452            if ( ! empty( $origin ) && 'GET' === $this->method ) {
453                header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
454            }
455
456            $this->path = substr( rtrim( $this->path, '/' ), 0, -5 );
457            // Show help for all matching endpoints regardless of method.
458            $methods                     = $allowed_methods;
459            $find_all_matching_endpoints = true;
460            // How deep to truncate each endpoint's path to see if it matches this help request.
461            $depth = substr_count( $this->path, '/' ) + 1;
462            if ( false !== stripos( $this->accept, 'javascript' ) || false !== stripos( $this->accept, 'json' ) ) {
463                $help_content_type = 'json';
464            } else {
465                $help_content_type = 'html';
466            }
467        } elseif ( in_array( $this->method, $allowed_methods, true ) ) {
468            // Only serve requested method.
469            $methods                     = array( $this->method );
470            $find_all_matching_endpoints = false;
471        } else {
472            // We don't allow this requested method - find matching endpoints and send 405.
473            $methods                     = $allowed_methods;
474            $find_all_matching_endpoints = true;
475            $four_oh_five                = true;
476        }
477
478        // Find which endpoint to serve.
479        $found = false;
480        foreach ( $this->endpoints as $endpoint_path_versions => $endpoints_by_method ) {
481            // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
482            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Legacy, possibly depended on elsewhere.
483            $endpoint_path_versions = unserialize( $endpoint_path_versions );
484            $endpoint_path          = $endpoint_path_versions[0];
485            $endpoint_min_version   = $endpoint_path_versions[1];
486            $endpoint_max_version   = $endpoint_path_versions[2];
487
488            // Make sure max_version is not less than min_version.
489            if ( version_compare( $endpoint_max_version, $endpoint_min_version, '<' ) ) {
490                $endpoint_max_version = $endpoint_min_version;
491            }
492
493            foreach ( $methods as $method ) {
494                if ( ! isset( $endpoints_by_method[ $method ] ) ) {
495                    continue;
496                }
497
498                // Normalize.
499                $endpoint_path = untrailingslashit( $endpoint_path );
500                if ( $is_help ) {
501                    // Truncate path at help depth.
502                    $endpoint_path = implode( '/', array_slice( explode( '/', $endpoint_path ), 0, $depth ) );
503                }
504
505                // Generate regular expression from sprintf().
506                $endpoint_path_regex = str_replace( array( '%s', '%d' ), array( '([^/?&]+)', '(\d+)' ), $endpoint_path );
507
508                if ( ! preg_match( "#^$endpoint_path_regex\$#", $this->path, $path_pieces ) ) {
509                    // This endpoint does not match the requested path.
510                    continue;
511                }
512
513                if ( version_compare( $this->version, $endpoint_min_version, '<' ) || version_compare( $this->version, $endpoint_max_version, '>' ) ) {
514                    // This endpoint does not match the requested version.
515                    continue;
516                }
517
518                $found = true;
519
520                if ( $find_all_matching_endpoints ) {
521                    $matching_endpoints[] = array( $endpoints_by_method[ $method ], $path_pieces );
522                } else {
523                    // The method parameters are now in $path_pieces.
524                    $endpoint = $endpoints_by_method[ $method ];
525                    break 2;
526                }
527            }
528        }
529
530        if ( ! $found ) {
531            return $this->output( 404, '', 'text/plain' );
532        }
533
534        if ( $four_oh_five ) {
535            $allowed_methods = array();
536            foreach ( $matching_endpoints as $matching_endpoint ) {
537                $allowed_methods[] = $matching_endpoint[0]->method;
538            }
539
540            header( 'Allow: ' . strtoupper( implode( ',', array_unique( $allowed_methods ) ) ) );
541            return $this->output(
542                405,
543                array(
544                    'error'         => 'not_allowed',
545                    'error_message' => 'Method not allowed',
546                )
547            );
548        }
549
550        if ( $is_help ) {
551            /**
552             * Fires before the API output.
553             *
554             * @since 1.9.0
555             *
556             * @param string help.
557             */
558            do_action( 'wpcom_json_api_output', 'help' );
559            $proxied = function_exists( 'wpcom_is_proxied_request' ) ? wpcom_is_proxied_request() : false;
560            if ( 'json' === $help_content_type ) {
561                $docs = array();
562                foreach ( $matching_endpoints as $matching_endpoint ) {
563                    if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
564                        $docs[] = call_user_func( array( $matching_endpoint[0], 'generate_documentation' ) );
565                    }
566                }
567                return $this->output( 200, $docs );
568            } else {
569                status_header( 200 );
570                foreach ( $matching_endpoints as $matching_endpoint ) {
571                    if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
572                        call_user_func( array( $matching_endpoint[0], 'document' ) );
573                    }
574                }
575            }
576            exit( 0 );
577        }
578
579        if ( $endpoint->in_testing && ! WPCOM_JSON_API__DEBUG ) {
580            return $this->output( 404, '', 'text/plain' );
581        }
582
583        /** This action is documented in class.json-api.php */
584        do_action( 'wpcom_json_api_output', $endpoint->stat );
585
586        $response = $this->process_request( $endpoint, $path_pieces );
587
588        if ( ! $response && ! is_array( $response ) ) {
589            return $this->output( 500, '', 'text/plain' );
590        } elseif ( is_wp_error( $response ) ) {
591            return $this->output_error( $response );
592        }
593
594        $output_status_code = $this->output_status_code;
595        $this->set_output_status_code();
596
597        return $this->output( $output_status_code, $response, 'application/json', $this->extra_headers );
598    }
599
600    /**
601     * Process a request.
602     *
603     * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint.
604     * @param array                   $path_pieces Path pieces.
605     * @return array|WP_Error Return value from the endpoint's callback.
606     */
607    public function process_request( WPCOM_JSON_API_Endpoint $endpoint, $path_pieces ) {
608        $this->endpoint = $endpoint;
609        $this->maybe_switch_to_token_user_and_site();
610        return call_user_func_array( array( $endpoint, 'callback' ), $path_pieces );
611    }
612
613    /**
614     * Output a response or error without exiting.
615     *
616     * @param int    $status_code HTTP status code.
617     * @param mixed  $response Response data.
618     * @param string $content_type Content type of the response.
619     */
620    public function output_early( $status_code, $response = null, $content_type = 'application/json' ) {
621        $exit       = $this->exit;
622        $this->exit = false;
623        if ( is_wp_error( $response ) ) {
624            $this->output_error( $response );
625        } else {
626            $this->output( $status_code, $response, $content_type );
627        }
628        $this->exit = $exit;
629        if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
630            $this->finish_request();
631        }
632    }
633
634    /**
635     * Set output status code.
636     *
637     * @param int $code HTTP status code.
638     */
639    public function set_output_status_code( $code = 200 ) {
640        $this->output_status_code = $code;
641    }
642
643    /**
644     * Output a response.
645     *
646     * @param int    $status_code HTTP status code.
647     * @param mixed  $response Response data.
648     * @param string $content_type Content type of the response.
649     * @param array  $extra Additional HTTP headers.
650     * @return string Content type (assuming it didn't exit).
651     */
652    public function output( $status_code, $response = null, $content_type = 'application/json', $extra = array() ) {
653        $status_code = (int) $status_code;
654
655        // In case output() was called before the callback returned.
656        if ( $this->did_output ) {
657            if ( $this->exit ) {
658                exit( 0 );
659            }
660            return $content_type;
661        }
662        $this->did_output = true;
663
664        // 400s and 404s are allowed for all origins
665        if ( 404 === $status_code || 400 === $status_code ) {
666            header( 'Access-Control-Allow-Origin: *' );
667        }
668
669        /* Add headers for form submission from <amp-form/> */
670        if ( $this->amp_source_origin ) {
671            header( 'Access-Control-Allow-Origin: ' . wp_unslash( $this->amp_source_origin ) );
672            header( 'Access-Control-Allow-Credentials: true' );
673        }
674
675        if ( $response === null ) {
676            $response = new stdClass();
677        }
678
679        if ( 'text/plain' === $content_type ||
680            'text/html' === $content_type ) {
681            status_header( $status_code );
682            header( 'Content-Type: ' . $content_type );
683            foreach ( $extra as $key => $value ) {
684                header( "$key$value" );
685            }
686            echo $response; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
687            if ( $this->exit ) {
688                exit( 0 );
689            }
690
691            return $content_type;
692        }
693
694        $response = $this->filter_fields( $response );
695
696        if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) {
697            $response = static::wrap_http_envelope( $status_code, $response, $content_type, $extra );
698
699            $status_code  = 200;
700            $content_type = 'application/json';
701        }
702
703        status_header( $status_code );
704        header( "Content-Type: $content_type" );
705        if ( isset( $this->query['callback'] ) && is_string( $this->query['callback'] ) ) {
706            $callback = preg_replace( '/[^a-z0-9_.]/i', '', $this->query['callback'] );
707        } else {
708            $callback = false;
709        }
710
711        if ( $callback ) {
712            // Mitigate Rosetta Flash [1] by setting the Content-Type-Options: nosniff header
713            // and by prepending the JSONP response with a JS comment.
714            // [1] <https://blog.miki.it/2014/7/8/abusing-jsonp-with-rosetta-flash/index.html>.
715            echo "/**/$callback("; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSONP output, not HTML.
716
717        }
718        echo $this->json_encode( $response ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSON or JSONP output, not HTML.
719        if ( $callback ) {
720            echo ');';
721        }
722
723        if ( $this->exit ) {
724            exit( 0 );
725        }
726
727        return $content_type;
728    }
729
730    /**
731     * Wrap JSON API response into an HTTP 200 one.
732     *
733     * @param int        $status_code HTTP status code.
734     * @param mixed      $response Response body.
735     * @param string     $content_type Content type.
736     * @param array|null $extra Extra data.
737     *
738     * @return array
739     */
740    public static function wrap_http_envelope( $status_code, $response, $content_type, $extra = null ) {
741        $headers = array(
742            array(
743                'name'  => 'Content-Type',
744                'value' => $content_type,
745            ),
746        );
747
748        if ( is_array( $extra ) ) {
749            foreach ( $extra as $key => $value ) {
750                $headers[] = array(
751                    'name'  => $key,
752                    'value' => $value,
753                );
754            }
755        }
756
757        return array(
758            'code'    => (int) $status_code,
759            'headers' => $headers,
760            'body'    => $response,
761        );
762    }
763
764    /**
765     * Serialize an error.
766     *
767     * @param WP_Error $error Error.
768     * @return array with 'status_code' and 'errors' data.
769     */
770    public static function serializable_error( $error ) {
771
772        $status_code = $error->get_error_data();
773
774        if ( is_array( $status_code ) && isset( $status_code['status_code'] ) ) {
775            $status_code = $status_code['status_code'];
776        }
777
778        if ( ! $status_code ) {
779            $status_code = 400;
780        }
781        $response = array(
782            'error'   => $error->get_error_code(),
783            'message' => $error->get_error_message(),
784        );
785
786        $additional_data = $error->get_error_data( 'additional_data' );
787        if ( $additional_data ) {
788            $response['data'] = $additional_data;
789        }
790
791        return array(
792            'status_code' => $status_code,
793            'errors'      => $response,
794        );
795    }
796
797    /**
798     * Output an error.
799     *
800     * @param WP_Error $error Error.
801     * @return string Content type (assuming it didn't exit).
802     */
803    public function output_error( $error ) {
804        $error_response = static::serializable_error( $error );
805
806        return $this->output( $error_response['status_code'], $error_response['errors'] );
807    }
808
809    /**
810     * Filter fields in a response.
811     *
812     * @param array|object $response Response.
813     * @return array|object Filtered response.
814     */
815    public function filter_fields( $response ) {
816        if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) ) {
817            return $response;
818        }
819
820        $fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
821
822        if ( is_object( $response ) ) {
823            $response = (array) $response;
824        }
825
826        $has_filtered = false;
827        if ( is_array( $response ) && empty( $response['ID'] ) ) {
828            $keys_to_filter = array(
829                'categories',
830                'comments',
831                'connections',
832                'domains',
833                'groups',
834                'likes',
835                'media',
836                'notes',
837                'posts',
838                'services',
839                'sites',
840                'suggestions',
841                'tags',
842                'themes',
843                'topics',
844                'users',
845            );
846
847            foreach ( $keys_to_filter as $key_to_filter ) {
848                if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered ) {
849                    continue;
850                }
851
852                foreach ( $response[ $key_to_filter ] as $key => $values ) {
853                    if ( is_object( $values ) ) {
854                        if ( is_object( $response[ $key_to_filter ] ) ) {
855                            // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found -- False positive.
856                            $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
857                        } elseif ( is_array( $response[ $key_to_filter ] ) ) {
858                            $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
859                        }
860                    } elseif ( is_array( $values ) ) {
861                        $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
862                    }
863                }
864
865                $has_filtered = true;
866            }
867        }
868
869        if ( ! $has_filtered ) {
870            if ( is_object( $response ) ) {
871                $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
872            } elseif ( is_array( $response ) ) {
873                $response = array_intersect_key( $response, array_flip( $fields ) );
874            }
875        }
876
877        return $response;
878    }
879
880    /**
881     * Filter for `home_url`.
882     *
883     * If `$original_scheme` is null, turns an https URL to http.
884     *
885     * @param string      $url The complete home URL including scheme and path.
886     * @param string      $path Path relative to the home URL. Blank string if no path is specified.
887     * @param string|null $original_scheme Scheme to give the home URL context. Accepts 'http', 'https', 'relative', 'rest', or null.
888     * @return string URL.
889     */
890    public function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
891        if ( $original_scheme ) {
892            return $url;
893        }
894
895        return preg_replace( '#^https:#', 'http:', $url );
896    }
897
898    /**
899     * Decode HTML special characters in comment content.
900     *
901     * @param string $comment_content Comment content.
902     * @return string
903     */
904    public function comment_edit_pre( $comment_content ) {
905        return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
906    }
907
908    /**
909     * JSON encode.
910     *
911     * @param mixed $data Data.
912     * @return string|false
913     */
914    public function json_encode( $data ) {
915        return wp_json_encode( $data );
916    }
917
918    /**
919     * Test if a string ends with a string.
920     *
921     * @param string $haystack String to check.
922     * @param string $needle Suffix to check.
923     * @return bool
924     */
925    public function ends_with( $haystack, $needle ) {
926        return substr( $haystack, -strlen( $needle ) ) === $needle;
927    }
928
929    /**
930     * Returns the site's blog_id in the WP.com ecosystem
931     *
932     * @return int
933     */
934    public function get_blog_id_for_output() {
935        return $this->token_details['blog_id'];
936    }
937
938    /**
939     * Returns the site's local blog_id.
940     *
941     * @param int $blog_id Blog ID.
942     * @return int
943     */
944    public function get_blog_id( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
945        return $GLOBALS['blog_id'];
946    }
947
948    /**
949     * Switch to blog and validate user.
950     *
951     * @param int  $blog_id Blog ID.
952     * @param bool $verify_token_for_blog Whether to verify the token.
953     * @return int Blog ID.
954     */
955    public function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
956        if ( $this->is_restricted_blog( $blog_id ) ) {
957            return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
958        }
959        /**
960         * If this is a private site we check for 2 things:
961         * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
962         * 2. In case of site based authentication, make sure the endpoint accepts it.
963         */
964        if ( ( new Status() )->is_private_site() &&
965            ! current_user_can( 'read' ) &&
966            ! $this->endpoint->accepts_site_based_authentication()
967        ) {
968            return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
969        }
970
971        return $blog_id;
972    }
973
974    /**
975     * Switch to a user and blog based on the current request's Jetpack token when the endpoint accepts this feature.
976     *
977     * @return void
978     */
979    protected function maybe_switch_to_token_user_and_site() {
980        if ( ! $this->endpoint->allow_jetpack_token_auth ) {
981            return;
982        }
983
984        if ( ! class_exists( 'Jetpack_Server_Version' ) ) {
985            return;
986        }
987
988        $token = Jetpack_Server_Version::get_token_from_authorization_header();
989
990        if ( ! $token || is_wp_error( $token ) ) {
991            return;
992        }
993
994        if ( get_current_user_id() !== $token->user_id ) {
995            wp_set_current_user( $token->user_id );
996        }
997
998        if ( get_current_blog_id() !== $token->blog_id ) {
999            switch_to_blog( $token->blog_id );
1000        }
1001    }
1002
1003    /**
1004     * Returns true if the specified blog ID is a restricted blog
1005     *
1006     * @param int $blog_id Blog ID.
1007     * @return bool
1008     */
1009    public function is_restricted_blog( $blog_id ) {
1010        /**
1011         * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
1012         *
1013         * @module json-api
1014         *
1015         * @since 3.4.0
1016         *
1017         * @param array $array Array of Blog IDs.
1018         */
1019        $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
1020        return true === in_array( $blog_id, $restricted_blog_ids ); // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- I don't trust filters to return the right types.
1021    }
1022
1023    /**
1024     * Post like count.
1025     *
1026     * @param int $blog_id Blog ID.
1027     * @param int $post_id Post ID.
1028     * @return int
1029     */
1030    public function post_like_count( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1031        return 0;
1032    }
1033
1034    /**
1035     * Is liked?
1036     *
1037     * @param int $blog_id Blog ID.
1038     * @param int $post_id Post ID.
1039     * @return bool
1040     */
1041    public function is_liked( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1042        return false;
1043    }
1044
1045    /**
1046     * Is reblogged?
1047     *
1048     * @param int $blog_id Blog ID.
1049     * @param int $post_id Post ID.
1050     * @return bool
1051     */
1052    public function is_reblogged( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1053        return false;
1054    }
1055
1056    /**
1057     * Is following?
1058     *
1059     * @param int $blog_id Blog ID.
1060     * @return bool
1061     */
1062    public function is_following( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1063        return false;
1064    }
1065
1066    /**
1067     * Add global ID.
1068     *
1069     * @param int $blog_id Blog ID.
1070     * @param int $post_id Post ID.
1071     * @return string
1072     */
1073    public function add_global_ID( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
1074        return '';
1075    }
1076
1077    /**
1078     * Return a count of comment likes.
1079     * This method is overridden by a child class in WPCOM.
1080     *
1081     * @since 13.5
1082     * @return int
1083     */
1084    public function comment_like_count() {
1085        func_get_args(); // @phan-suppress-current-line PhanPluginUseReturnValueInternalKnown -- This is just here so Phan realizes the wpcom version does this.
1086        return 0;
1087    }
1088
1089    /**
1090     * Get avatar URL.
1091     *
1092     * @param string $email Email.
1093     * @param array  $args Args for `get_avatar_url()`.
1094     * @return string|false
1095     */
1096    public function get_avatar_url( $email, $args = null ) {
1097        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1098            $ret = wpcom_get_avatar_url( $email, $args['size'] ?? 96, $args['default'] ?? '', false, $args['force_default'] ?? false );
1099            return $ret ? $ret[0] : false;
1100        } else {
1101            return null === $args
1102                ? get_avatar_url( $email )
1103                : get_avatar_url( $email, $args );
1104        }
1105    }
1106
1107    /**
1108     * Counts the number of comments on a site, including certain comment types.
1109     *
1110     * @param int $post_id Post ID.
1111     * @return object The number of counts keyed by status, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
1112     */
1113    public function wp_count_comments( $post_id ) {
1114        global $wpdb;
1115        if ( 0 !== $post_id ) {
1116            return wp_count_comments( $post_id );
1117        }
1118
1119        $counts = array(
1120            'total_comments' => 0,
1121            'all'            => 0,
1122        );
1123
1124        /**
1125        * Exclude certain comment types from comment counts in the REST API.
1126        *
1127        * @since 6.9.0
1128        * @deprecated 11.1
1129        * @module json-api
1130        *
1131        * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
1132        */
1133        $exclude = apply_filters_deprecated( 'jetpack_api_exclude_comment_types_count', array( 'order_note', 'webhook_delivery', 'review', 'action_log' ), 'jetpack-11.1', 'jetpack_api_include_comment_types_count' ); // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1134
1135        /**
1136        * Include certain comment types in comment counts in the REST API.
1137        * Note: the default array of comment types includes an empty string,
1138        * to support comments posted before WP 5.5, that used an empty string as comment type.
1139        *
1140        * @since 11.1
1141        * @module json-api
1142        *
1143        * @param array Array of comment types to include (default: 'comment', 'pingback', 'trackback')
1144        */
1145        $include = apply_filters(
1146            'jetpack_api_include_comment_types_count',
1147            array( 'comment', 'pingback', 'trackback', '' )
1148        );
1149
1150        if ( empty( $include ) ) {
1151            return wp_count_comments( $post_id );
1152        }
1153
1154        // The following caching mechanism is based on what the get_comments() function uses.
1155
1156        $key          = md5( serialize( $include ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
1157        $last_changed = wp_cache_get_last_changed( 'comment' );
1158
1159        $cache_key = "wp_count_comments:$key:$last_changed";
1160        $count     = wp_cache_get( $cache_key, 'jetpack-json-api' );
1161
1162        if ( false === $count ) {
1163            array_walk( $include, 'esc_sql' );
1164            $where = sprintf(
1165                "WHERE comment_type IN ( '%s' )",
1166                implode( "','", $include )
1167            );
1168
1169            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$where` is built with escaping just above.
1170            $count = $wpdb->get_results(
1171                "SELECT comment_approved, COUNT(*) AS num_comments
1172                    FROM $wpdb->comments
1173                    {$where}
1174                    GROUP BY comment_approved
1175                "
1176            );
1177            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1178
1179            wp_cache_add( $cache_key, $count, 'jetpack-json-api' );
1180        }
1181
1182        $approved = array(
1183            '0'            => 'moderated',
1184            '1'            => 'approved',
1185            'spam'         => 'spam',
1186            'trash'        => 'trash',
1187            'post-trashed' => 'post-trashed',
1188        );
1189
1190        // <https://developer.wordpress.org/reference/functions/get_comment_count/#source>
1191        foreach ( $count as $row ) {
1192            if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
1193                $counts['all']            += $row->num_comments;
1194                $counts['total_comments'] += $row->num_comments;
1195            } elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
1196                $counts['total_comments'] += $row->num_comments;
1197            }
1198            if ( isset( $approved[ $row->comment_approved ] ) ) {
1199                $counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
1200            }
1201        }
1202
1203        foreach ( $approved as $key ) {
1204            if ( empty( $counts[ $key ] ) ) {
1205                $counts[ $key ] = 0;
1206            }
1207        }
1208
1209        $counts = (object) $counts;
1210
1211        return $counts;
1212    }
1213
1214    /**
1215     * Traps `wp_die()` calls and outputs a JSON response instead.
1216     * The result is always output, never returned.
1217     *
1218     * @param string|null $error_code  Call with string to start the trapping.  Call with null to stop.
1219     * @param int         $http_status  HTTP status code, 400 by default.
1220     */
1221    public function trap_wp_die( $error_code = null, $http_status = 400 ) {
1222        // Determine the filter name; based on the conditionals inside the wp_die function.
1223        if ( wp_is_json_request() ) {
1224            $die_handler = 'wp_die_json_handler';
1225        } elseif ( wp_is_jsonp_request() ) {
1226            $die_handler = 'wp_die_jsonp_handler';
1227        } elseif ( wp_is_xml_request() ) {
1228            $die_handler = 'wp_die_xml_handler';
1229        } else {
1230            $die_handler = 'wp_die_handler';
1231        }
1232
1233        if ( $error_code === null ) {
1234            $this->trapped_error = null;
1235            // Stop trapping.
1236            remove_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1237            return;
1238        }
1239
1240        // If API called via PHP, bail: don't do our custom wp_die().  Do the normal wp_die().
1241        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1242            if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
1243                return;
1244            }
1245        } elseif ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
1246            return;
1247        }
1248
1249        $this->trapped_error = array(
1250            'status'  => $http_status,
1251            'code'    => $error_code,
1252            'message' => '',
1253        );
1254        // Start trapping.
1255        add_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1256    }
1257
1258    /**
1259     * Filter function for `wp_die_handler` and similar filters.
1260     *
1261     * @return callable
1262     */
1263    public function wp_die_handler_callback() {
1264        return array( $this, 'wp_die_handler' );
1265    }
1266
1267    /**
1268     * Handler for `wp_die` calls.
1269     *
1270     * @param string|WP_Error  $message As for `wp_die()`.
1271     * @param string|int       $title As for `wp_die()`.
1272     * @param string|array|int $args As for `wp_die()`.
1273     * @return never
1274     */
1275    public function wp_die_handler( $message, $title = '', $args = array() ) {
1276        // Allow wp_die calls to override HTTP status code...
1277        $args = wp_parse_args(
1278            $args,
1279            array(
1280                'response' => $this->trapped_error['status'],
1281            )
1282        );
1283
1284        // ... unless it's 500
1285        if ( 500 !== (int) $args['response'] ) {
1286            $this->trapped_error['status'] = $args['response'];
1287        }
1288
1289        if ( $title ) {
1290            $message = "$title$message";
1291        }
1292
1293        $this->trapped_error['message'] = wp_kses( $message, array() );
1294
1295        switch ( $this->trapped_error['code'] ) {
1296            case 'comment_failure':
1297                if ( did_action( 'comment_duplicate_trigger' ) ) {
1298                    $this->trapped_error['code'] = 'comment_duplicate';
1299                } elseif ( did_action( 'comment_flood_trigger' ) ) {
1300                    $this->trapped_error['code'] = 'comment_flood';
1301                }
1302                break;
1303        }
1304
1305        // We still want to exit so that code execution stops where it should.
1306        // Attach the JSON output to the WordPress shutdown handler.
1307        add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
1308        exit( 0 );
1309    }
1310
1311    /**
1312     * Output the trapped error.
1313     */
1314    public function output_trapped_error() {
1315        $this->exit = false; // We're already exiting once.  Don't do it twice.
1316        $this->output(
1317            $this->trapped_error['status'],
1318            (object) array(
1319                'error'   => $this->trapped_error['code'],
1320                'message' => $this->trapped_error['message'],
1321            )
1322        );
1323    }
1324
1325    /**
1326     * Finish the request.
1327     */
1328    public function finish_request() {
1329        if ( function_exists( 'fastcgi_finish_request' ) ) {
1330            return fastcgi_finish_request();
1331        }
1332    }
1333
1334    /**
1335     * Initialize the locale if different from 'en'.
1336     *
1337     * @param string $locale The locale to initialize.
1338     */
1339    public function init_locale( $locale ) {
1340        if ( 'en' !== $locale ) {
1341            // .org mo files are named slightly different from .com, and all we have is this the locale -- try to guess them.
1342            $new_locale = $locale;
1343            if ( str_contains( $locale, '-' ) ) {
1344                $locale_pieces = explode( '-', $locale );
1345                $new_locale    = $locale_pieces[0];
1346                $new_locale   .= ( ! empty( $locale_pieces[1] ) ) ? '_' . strtoupper( $locale_pieces[1] ) : '';
1347            } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
1348                // .com might pass 'fr' because thats what our language files are named as, where core seems
1349                // to do fr_FR - so try that if we don't think we can load the file.
1350                if ( ! file_exists( WP_LANG_DIR . '/' . $locale . '.mo' ) ) {
1351                    $new_locale = $locale . '_' . strtoupper( $locale );
1352                }
1353            }
1354
1355            if ( file_exists( WP_LANG_DIR . '/' . $new_locale . '.mo' ) ) {
1356                unload_textdomain( 'default' );
1357                load_textdomain( 'default', WP_LANG_DIR . '/' . $new_locale . '.mo' );
1358            }
1359        }
1360    }
1361}