Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.05% covered (danger)
9.05%
43 / 475
12.20% covered (danger)
12.20%
5 / 41
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_JSON_API
9.11% covered (danger)
9.11%
43 / 472
12.20% covered (danger)
12.20%
5 / 41
31758.94
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 / 102
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_Disallowed
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        $path_pieces = array();
481        foreach ( $this->endpoints as $endpoint_path_versions => $endpoints_by_method ) {
482            // @todo Determine if anything depends on this being serialized rather than e.g. JSON.
483            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- Legacy, possibly depended on elsewhere.
484            $endpoint_path_versions = unserialize( $endpoint_path_versions );
485            $endpoint_path          = $endpoint_path_versions[0];
486            $endpoint_min_version   = $endpoint_path_versions[1];
487            $endpoint_max_version   = $endpoint_path_versions[2];
488
489            // Make sure max_version is not less than min_version.
490            if ( version_compare( $endpoint_max_version, $endpoint_min_version, '<' ) ) {
491                $endpoint_max_version = $endpoint_min_version;
492            }
493
494            foreach ( $methods as $method ) {
495                if ( ! isset( $endpoints_by_method[ $method ] ) ) {
496                    continue;
497                }
498
499                // Normalize.
500                $endpoint_path = untrailingslashit( $endpoint_path );
501                if ( $is_help ) {
502                    // Truncate path at help depth.
503                    // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $depth is set when $is_help is true.
504                    $endpoint_path = implode( '/', array_slice( explode( '/', $endpoint_path ), 0, $depth ) );
505                }
506
507                // Generate regular expression from sprintf().
508                $endpoint_path_regex = str_replace( array( '%s', '%d' ), array( '([^/?&]+)', '(\d+)' ), $endpoint_path );
509
510                if ( ! preg_match( "#^$endpoint_path_regex\$#", $this->path, $path_pieces ) ) {
511                    // This endpoint does not match the requested path.
512                    continue;
513                }
514
515                if ( version_compare( $this->version, $endpoint_min_version, '<' ) || version_compare( $this->version, $endpoint_max_version, '>' ) ) {
516                    // This endpoint does not match the requested version.
517                    continue;
518                }
519
520                $found = true;
521
522                if ( $find_all_matching_endpoints ) {
523                    $matching_endpoints[] = array( $endpoints_by_method[ $method ], $path_pieces );
524                } else {
525                    // The method parameters are now in $path_pieces.
526                    $endpoint = $endpoints_by_method[ $method ];
527                    break 2;
528                }
529            }
530        }
531
532        if ( ! $found ) {
533            return $this->output( 404, '', 'text/plain' );
534        }
535
536        if ( $four_oh_five ) {
537            $allowed_methods = array();
538            foreach ( $matching_endpoints as $matching_endpoint ) {
539                $allowed_methods[] = $matching_endpoint[0]->method;
540            }
541
542            header( 'Allow: ' . strtoupper( implode( ',', array_unique( $allowed_methods ) ) ) );
543            return $this->output(
544                405,
545                array(
546                    'error'         => 'not_allowed',
547                    'error_message' => 'Method not allowed',
548                )
549            );
550        }
551
552        if ( $is_help ) {
553            /**
554             * Fires before the API output.
555             *
556             * @since 1.9.0
557             *
558             * @param string help.
559             */
560            do_action( 'wpcom_json_api_output', 'help' );
561            $proxied = function_exists( 'wpcom_is_proxied_request' ) ? wpcom_is_proxied_request() : false;
562            // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $help_content_type is set when $is_help is true.
563            if ( 'json' === $help_content_type ) {
564                $docs = array();
565                foreach ( $matching_endpoints as $matching_endpoint ) {
566                    if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
567                        $docs[] = call_user_func( array( $matching_endpoint[0], 'generate_documentation' ) );
568                    }
569                }
570                return $this->output( 200, $docs );
571            } else {
572                status_header( 200 );
573                foreach ( $matching_endpoints as $matching_endpoint ) {
574                    if ( $matching_endpoint[0]->is_publicly_documentable() || $proxied || WPCOM_JSON_API__DEBUG ) {
575                        call_user_func( array( $matching_endpoint[0], 'document' ) );
576                    }
577                }
578            }
579            exit( 0 );
580        }
581
582        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $endpoint is set when $find_all_matching_endpoints is false and $found is true, which is guaranteed here.
583        if ( $endpoint->in_testing && ! WPCOM_JSON_API__DEBUG ) {
584            return $this->output( 404, '', 'text/plain' );
585        }
586
587        /** This action is documented in class.json-api.php */
588        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $endpoint is set when $find_all_matching_endpoints is false and $found is true, which is guaranteed here.
589        do_action( 'wpcom_json_api_output', $endpoint->stat );
590
591        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable -- $endpoint is set when $find_all_matching_endpoints is false and $found is true, which is guaranteed here.
592        $response = $this->process_request( $endpoint, $path_pieces );
593
594        if ( ! $response && ! is_array( $response ) ) {
595            return $this->output( 500, '', 'text/plain' );
596        } elseif ( is_wp_error( $response ) ) {
597            return $this->output_error( $response );
598        }
599
600        $output_status_code = $this->output_status_code;
601        $this->set_output_status_code();
602
603        return $this->output( $output_status_code, $response, 'application/json', $this->extra_headers );
604    }
605
606    /**
607     * Process a request.
608     *
609     * @param WPCOM_JSON_API_Endpoint $endpoint Endpoint.
610     * @param array                   $path_pieces Path pieces.
611     * @return array|WP_Error Return value from the endpoint's callback.
612     */
613    public function process_request( WPCOM_JSON_API_Endpoint $endpoint, $path_pieces ) {
614        $this->endpoint = $endpoint;
615        $this->maybe_switch_to_token_user_and_site();
616        return call_user_func_array( array( $endpoint, 'callback' ), $path_pieces );
617    }
618
619    /**
620     * Output a response or error without exiting.
621     *
622     * @param int    $status_code HTTP status code.
623     * @param mixed  $response Response data.
624     * @param string $content_type Content type of the response.
625     */
626    public function output_early( $status_code, $response = null, $content_type = 'application/json' ) {
627        $exit       = $this->exit;
628        $this->exit = false;
629        if ( is_wp_error( $response ) ) {
630            $this->output_error( $response );
631        } else {
632            $this->output( $status_code, $response, $content_type );
633        }
634        $this->exit = $exit;
635        if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
636            $this->finish_request();
637        }
638    }
639
640    /**
641     * Set output status code.
642     *
643     * @param int $code HTTP status code.
644     */
645    public function set_output_status_code( $code = 200 ) {
646        $this->output_status_code = $code;
647    }
648
649    /**
650     * Output a response.
651     *
652     * @param int    $status_code HTTP status code.
653     * @param mixed  $response Response data.
654     * @param string $content_type Content type of the response.
655     * @param array  $extra Additional HTTP headers.
656     * @return string Content type (assuming it didn't exit).
657     */
658    public function output( $status_code, $response = null, $content_type = 'application/json', $extra = array() ) {
659        $status_code = (int) $status_code;
660
661        // In case output() was called before the callback returned.
662        if ( $this->did_output ) {
663            if ( $this->exit ) {
664                exit( 0 );
665            }
666            return $content_type;
667        }
668        $this->did_output = true;
669
670        // 400s and 404s are allowed for all origins
671        if ( 404 === $status_code || 400 === $status_code ) {
672            header( 'Access-Control-Allow-Origin: *' );
673        }
674
675        /* Add headers for form submission from <amp-form/> */
676        if ( $this->amp_source_origin ) {
677            header( 'Access-Control-Allow-Origin: ' . wp_unslash( $this->amp_source_origin ) );
678            header( 'Access-Control-Allow-Credentials: true' );
679        }
680
681        if ( $response === null ) {
682            $response = new stdClass();
683        }
684
685        if ( 'text/plain' === $content_type ||
686            'text/html' === $content_type ) {
687            status_header( $status_code );
688            header( 'Content-Type: ' . $content_type );
689            foreach ( $extra as $key => $value ) {
690                header( "$key$value" );
691            }
692            echo $response; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
693            if ( $this->exit ) {
694                exit( 0 );
695            }
696
697            return $content_type;
698        }
699
700        $response = $this->filter_fields( $response );
701
702        if ( isset( $this->query['http_envelope'] ) && self::is_truthy( $this->query['http_envelope'] ) ) {
703            $response = static::wrap_http_envelope( $status_code, $response, $content_type, $extra );
704
705            $status_code  = 200;
706            $content_type = 'application/json';
707        }
708
709        status_header( $status_code );
710        header( "Content-Type: $content_type" );
711        if ( isset( $this->query['callback'] ) && is_string( $this->query['callback'] ) ) {
712            $callback = preg_replace( '/[^a-z0-9_.]/i', '', $this->query['callback'] );
713        } else {
714            $callback = false;
715        }
716
717        if ( $callback ) {
718            // Mitigate Rosetta Flash [1] by setting the Content-Type-Options: nosniff header
719            // and by prepending the JSONP response with a JS comment.
720            // [1] <https://blog.miki.it/2014/7/8/abusing-jsonp-with-rosetta-flash/index.html>.
721            echo "/**/$callback("; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSONP output, not HTML.
722
723        }
724        echo $this->json_encode( $response, JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- This is JSON or JSONP output, not HTML.
725        if ( $callback ) {
726            echo ');';
727        }
728
729        if ( $this->exit ) {
730            exit( 0 );
731        }
732
733        return $content_type;
734    }
735
736    /**
737     * Wrap JSON API response into an HTTP 200 one.
738     *
739     * @param int        $status_code HTTP status code.
740     * @param mixed      $response Response body.
741     * @param string     $content_type Content type.
742     * @param array|null $extra Extra data.
743     *
744     * @return array
745     */
746    public static function wrap_http_envelope( $status_code, $response, $content_type, $extra = null ) {
747        $headers = array(
748            array(
749                'name'  => 'Content-Type',
750                'value' => $content_type,
751            ),
752        );
753
754        if ( is_array( $extra ) ) {
755            foreach ( $extra as $key => $value ) {
756                $headers[] = array(
757                    'name'  => $key,
758                    'value' => $value,
759                );
760            }
761        }
762
763        return array(
764            'code'    => (int) $status_code,
765            'headers' => $headers,
766            'body'    => $response,
767        );
768    }
769
770    /**
771     * Serialize an error.
772     *
773     * @param WP_Error $error Error.
774     * @return array with 'status_code' and 'errors' data.
775     */
776    public static function serializable_error( $error ) {
777
778        $status_code = $error->get_error_data();
779
780        if ( is_array( $status_code ) && isset( $status_code['status_code'] ) ) {
781            $status_code = $status_code['status_code'];
782        }
783
784        if ( ! $status_code ) {
785            $status_code = 400;
786        }
787        $response = array(
788            'error'   => $error->get_error_code(),
789            'message' => $error->get_error_message(),
790        );
791
792        $additional_data = $error->get_error_data( 'additional_data' );
793        if ( $additional_data ) {
794            $response['data'] = $additional_data;
795        }
796
797        return array(
798            'status_code' => $status_code,
799            'errors'      => $response,
800        );
801    }
802
803    /**
804     * Output an error.
805     *
806     * @param WP_Error $error Error.
807     * @return string Content type (assuming it didn't exit).
808     */
809    public function output_error( $error ) {
810        $error_response = static::serializable_error( $error );
811
812        return $this->output( $error_response['status_code'], $error_response['errors'] );
813    }
814
815    /**
816     * Filter fields in a response.
817     *
818     * @param array|object $response Response.
819     * @return array|object Filtered response.
820     */
821    public function filter_fields( $response ) {
822        if ( empty( $this->query['fields'] ) || ( is_array( $response ) && ! empty( $response['error'] ) ) || ! empty( $this->endpoint->custom_fields_filtering ) ) {
823            return $response;
824        }
825
826        $fields = array_map( 'trim', explode( ',', $this->query['fields'] ) );
827
828        if ( is_object( $response ) ) {
829            $response = (array) $response;
830        }
831
832        $has_filtered = false;
833        if ( is_array( $response ) && empty( $response['ID'] ) ) {
834            $keys_to_filter = array(
835                'categories',
836                'comments',
837                'connections',
838                'domains',
839                'groups',
840                'likes',
841                'media',
842                'notes',
843                'posts',
844                'services',
845                'sites',
846                'suggestions',
847                'tags',
848                'themes',
849                'topics',
850                'users',
851            );
852
853            foreach ( $keys_to_filter as $key_to_filter ) {
854                if ( ! isset( $response[ $key_to_filter ] ) || $has_filtered ) {
855                    continue;
856                }
857
858                foreach ( $response[ $key_to_filter ] as $key => $values ) {
859                    if ( is_object( $values ) ) {
860                        if ( is_object( $response[ $key_to_filter ] ) ) {
861                            $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
862                        } elseif ( is_array( $response[ $key_to_filter ] ) ) {
863                            $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
864                        }
865                    } elseif ( is_array( $values ) ) {
866                        $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
867                    }
868                }
869
870                $has_filtered = true;
871            }
872        }
873
874        if ( ! $has_filtered ) {
875            if ( is_object( $response ) ) {
876                $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
877            } elseif ( is_array( $response ) ) {
878                $response = array_intersect_key( $response, array_flip( $fields ) );
879            }
880        }
881
882        return $response;
883    }
884
885    /**
886     * Filter for `home_url`.
887     *
888     * If `$original_scheme` is null, turns an https URL to http.
889     *
890     * @param string      $url The complete home URL including scheme and path.
891     * @param string      $path Path relative to the home URL. Blank string if no path is specified.
892     * @param string|null $original_scheme Scheme to give the home URL context. Accepts 'http', 'https', 'relative', 'rest', or null.
893     * @return string URL.
894     */
895    public function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
896        if ( $original_scheme ) {
897            return $url;
898        }
899
900        return preg_replace( '#^https:#', 'http:', $url );
901    }
902
903    /**
904     * Decode HTML special characters in comment content.
905     *
906     * @param string $comment_content Comment content.
907     * @return string
908     */
909    public function comment_edit_pre( $comment_content ) {
910        return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
911    }
912
913    /**
914     * JSON encode.
915     *
916     * @param mixed $value   The value to encode.
917     * @param int   $flags   Options to be passed to json_encode(). Default 0.
918     * @param int   $depth   Maximum depth to walk through $value. Must be greater than 0.
919     *
920     * @return string|false
921     */
922    public function json_encode( $value, $flags = 0, $depth = 512 ) {
923        return wp_json_encode( $value, $flags, $depth );
924    }
925
926    /**
927     * Test if a string ends with a string.
928     *
929     * @param string $haystack String to check.
930     * @param string $needle Suffix to check.
931     * @return bool
932     */
933    public function ends_with( $haystack, $needle ) {
934        return substr( $haystack, -strlen( $needle ) ) === $needle;
935    }
936
937    /**
938     * Returns the site's blog_id in the WP.com ecosystem
939     *
940     * @return int
941     */
942    public function get_blog_id_for_output() {
943        return $this->token_details['blog_id'];
944    }
945
946    /**
947     * Returns the site's local blog_id.
948     *
949     * @param int $blog_id Blog ID.
950     * @return int
951     */
952    public function get_blog_id( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
953        return $GLOBALS['blog_id'];
954    }
955
956    /**
957     * Switch to blog and validate user.
958     *
959     * @param int  $blog_id Blog ID.
960     * @param bool $verify_token_for_blog Whether to verify the token.
961     * @return int Blog ID.
962     */
963    public function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
964        if ( $this->is_restricted_blog( $blog_id ) ) {
965            return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
966        }
967        /**
968         * If this is a private site we check for 2 things:
969         * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
970         * 2. In case of site based authentication, make sure the endpoint accepts it.
971         */
972        if ( ( new Status() )->is_private_site() &&
973            ! current_user_can( 'read' ) &&
974            ! $this->endpoint->accepts_site_based_authentication()
975        ) {
976            return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
977        }
978
979        return $blog_id;
980    }
981
982    /**
983     * Switch to a user and blog based on the current request's Jetpack token when the endpoint accepts this feature.
984     *
985     * @return void
986     */
987    protected function maybe_switch_to_token_user_and_site() {
988        if ( ! $this->endpoint->allow_jetpack_token_auth ) {
989            return;
990        }
991
992        if ( ! class_exists( 'Jetpack_Server_Version' ) ) {
993            return;
994        }
995
996        $token = Jetpack_Server_Version::get_token_from_authorization_header();
997
998        if ( ! $token || is_wp_error( $token ) ) {
999            return;
1000        }
1001
1002        if ( get_current_user_id() !== $token->user_id ) {
1003            wp_set_current_user( $token->user_id );
1004        }
1005
1006        if ( get_current_blog_id() !== $token->blog_id ) {
1007            switch_to_blog( $token->blog_id );
1008        }
1009    }
1010
1011    /**
1012     * Returns true if the specified blog ID is a restricted blog
1013     *
1014     * @param int $blog_id Blog ID.
1015     * @return bool
1016     */
1017    public function is_restricted_blog( $blog_id ) {
1018        /**
1019         * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
1020         *
1021         * @module json-api
1022         *
1023         * @since 3.4.0
1024         *
1025         * @param array $array Array of Blog IDs.
1026         */
1027        $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
1028        return in_array( $blog_id, $restricted_blog_ids ); // phpcs:ignore WordPress.PHP.StrictInArray.MissingTrueStrict -- I don't trust filters to return the right types.
1029    }
1030
1031    /**
1032     * Post like count.
1033     *
1034     * @param int $blog_id Blog ID.
1035     * @param int $post_id Post ID.
1036     * @return int
1037     */
1038    public function post_like_count( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1039        return 0;
1040    }
1041
1042    /**
1043     * Is liked?
1044     *
1045     * @param int $blog_id Blog ID.
1046     * @param int $post_id Post ID.
1047     * @return bool
1048     */
1049    public function is_liked( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1050        return false;
1051    }
1052
1053    /**
1054     * Is reblogged?
1055     *
1056     * @param int $blog_id Blog ID.
1057     * @param int $post_id Post ID.
1058     * @return bool
1059     */
1060    public function is_reblogged( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1061        return false;
1062    }
1063
1064    /**
1065     * Is following?
1066     *
1067     * @param int $blog_id Blog ID.
1068     * @return bool
1069     */
1070    public function is_following( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1071        return false;
1072    }
1073
1074    /**
1075     * Add global ID.
1076     *
1077     * @param int $blog_id Blog ID.
1078     * @param int $post_id Post ID.
1079     * @return string
1080     */
1081    public function add_global_ID( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
1082        return '';
1083    }
1084
1085    /**
1086     * Return a count of comment likes.
1087     * This method is overridden by a child class in WPCOM.
1088     *
1089     * @since 13.5
1090     * @return int
1091     */
1092    public function comment_like_count() {
1093        func_get_args(); // @phan-suppress-current-line PhanPluginUseReturnValueInternalKnown -- This is just here so Phan realizes the wpcom version does this.
1094        return 0;
1095    }
1096
1097    /**
1098     * Get avatar URL.
1099     *
1100     * @param string $email Email.
1101     * @param array  $args Args for `get_avatar_url()`.
1102     * @return string|false
1103     */
1104    public function get_avatar_url( $email, $args = null ) {
1105        if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1106            $ret = wpcom_get_avatar_url( $email, $args['size'] ?? 96, $args['default'] ?? '', false, $args['force_default'] ?? false );
1107            return $ret ? $ret[0] : false;
1108        } else {
1109            return null === $args
1110                ? get_avatar_url( $email )
1111                : get_avatar_url( $email, $args );
1112        }
1113    }
1114
1115    /**
1116     * Counts the number of comments on a site, including certain comment types.
1117     *
1118     * @param int $post_id Post ID.
1119     * @return object The number of counts keyed by status, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
1120     */
1121    public function wp_count_comments( $post_id ) {
1122        global $wpdb;
1123        if ( 0 !== $post_id ) {
1124            return wp_count_comments( $post_id );
1125        }
1126
1127        $counts = array(
1128            'total_comments' => 0,
1129            'all'            => 0,
1130        );
1131
1132        /**
1133        * Exclude certain comment types from comment counts in the REST API.
1134        *
1135        * @since 6.9.0
1136        * @deprecated 11.1
1137        * @module json-api
1138        *
1139        * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
1140        */
1141        $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
1142
1143        /**
1144        * Include certain comment types in comment counts in the REST API.
1145        * Note: the default array of comment types includes an empty string,
1146        * to support comments posted before WP 5.5, that used an empty string as comment type.
1147        *
1148        * @since 11.1
1149        * @module json-api
1150        *
1151        * @param array Array of comment types to include (default: 'comment', 'pingback', 'trackback')
1152        */
1153        $include = apply_filters(
1154            'jetpack_api_include_comment_types_count',
1155            array( 'comment', 'pingback', 'trackback', '' )
1156        );
1157
1158        if ( empty( $include ) ) {
1159            return wp_count_comments( $post_id );
1160        }
1161
1162        // The following caching mechanism is based on what the get_comments() function uses.
1163
1164        $key          = md5( serialize( $include ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
1165        $last_changed = wp_cache_get_last_changed( 'comment' );
1166
1167        $cache_key = "wp_count_comments:$key:$last_changed";
1168        $count     = wp_cache_get( $cache_key, 'jetpack-json-api' );
1169
1170        if ( false === $count ) {
1171            array_walk( $include, 'esc_sql' );
1172            $where = sprintf(
1173                "WHERE comment_type IN ( '%s' )",
1174                implode( "','", $include )
1175            );
1176
1177            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$where` is built with escaping just above.
1178            $count = $wpdb->get_results(
1179                "SELECT comment_approved, COUNT(*) AS num_comments
1180                    FROM $wpdb->comments
1181                    {$where}
1182                    GROUP BY comment_approved
1183                "
1184            );
1185            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1186
1187            wp_cache_add( $cache_key, $count, 'jetpack-json-api' );
1188        }
1189
1190        $approved = array(
1191            '0'            => 'moderated',
1192            '1'            => 'approved',
1193            'spam'         => 'spam',
1194            'trash'        => 'trash',
1195            'post-trashed' => 'post-trashed',
1196        );
1197
1198        // <https://developer.wordpress.org/reference/functions/get_comment_count/#source>
1199        foreach ( $count as $row ) {
1200            if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
1201                $counts['all']            += $row->num_comments;
1202                $counts['total_comments'] += $row->num_comments;
1203            } elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
1204                $counts['total_comments'] += $row->num_comments;
1205            }
1206            if ( isset( $approved[ $row->comment_approved ] ) ) {
1207                $counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
1208            }
1209        }
1210
1211        foreach ( $approved as $key ) {
1212            if ( empty( $counts[ $key ] ) ) {
1213                $counts[ $key ] = 0;
1214            }
1215        }
1216
1217        $counts = (object) $counts;
1218
1219        return $counts;
1220    }
1221
1222    /**
1223     * Traps `wp_die()` calls and outputs a JSON response instead.
1224     * The result is always output, never returned.
1225     *
1226     * @param string|null $error_code  Call with string to start the trapping.  Call with null to stop.
1227     * @param int         $http_status  HTTP status code, 400 by default.
1228     */
1229    public function trap_wp_die( $error_code = null, $http_status = 400 ) {
1230        // Determine the filter name; based on the conditionals inside the wp_die function.
1231        if ( wp_is_json_request() ) {
1232            $die_handler = 'wp_die_json_handler';
1233        } elseif ( wp_is_jsonp_request() ) {
1234            $die_handler = 'wp_die_jsonp_handler';
1235        } elseif ( wp_is_xml_request() ) {
1236            $die_handler = 'wp_die_xml_handler';
1237        } else {
1238            $die_handler = 'wp_die_handler';
1239        }
1240
1241        if ( $error_code === null ) {
1242            $this->trapped_error = null;
1243            // Stop trapping.
1244            remove_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1245            return;
1246        }
1247
1248        // If API called via PHP, bail: don't do our custom wp_die().  Do the normal wp_die().
1249        if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1250            if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
1251                return;
1252            }
1253        } elseif ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
1254            return;
1255        }
1256
1257        $this->trapped_error = array(
1258            'status'  => $http_status,
1259            'code'    => $error_code,
1260            'message' => '',
1261        );
1262        // Start trapping.
1263        add_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1264    }
1265
1266    /**
1267     * Filter function for `wp_die_handler` and similar filters.
1268     *
1269     * @return callable
1270     */
1271    public function wp_die_handler_callback() {
1272        return array( $this, 'wp_die_handler' );
1273    }
1274
1275    /**
1276     * Handler for `wp_die` calls.
1277     *
1278     * @param string|WP_Error  $message As for `wp_die()`.
1279     * @param string|int       $title As for `wp_die()`.
1280     * @param string|array|int $args As for `wp_die()`.
1281     * @return never
1282     */
1283    public function wp_die_handler( $message, $title = '', $args = array() ) {
1284        // Allow wp_die calls to override HTTP status code...
1285        $args = wp_parse_args(
1286            $args,
1287            array(
1288                'response' => $this->trapped_error['status'],
1289            )
1290        );
1291
1292        // ... unless it's 500
1293        if ( 500 !== (int) $args['response'] ) {
1294            $this->trapped_error['status'] = $args['response'];
1295        }
1296
1297        if ( $title ) {
1298            $message = "$title$message";
1299        }
1300
1301        $this->trapped_error['message'] = wp_kses( $message, array() );
1302
1303        switch ( $this->trapped_error['code'] ) {
1304            case 'comment_failure':
1305                if ( did_action( 'comment_duplicate_trigger' ) ) {
1306                    $this->trapped_error['code'] = 'comment_duplicate';
1307                } elseif ( did_action( 'comment_flood_trigger' ) ) {
1308                    $this->trapped_error['code'] = 'comment_flood';
1309                }
1310                break;
1311        }
1312
1313        // We still want to exit so that code execution stops where it should.
1314        // Attach the JSON output to the WordPress shutdown handler.
1315        add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
1316        exit( 0 );
1317    }
1318
1319    /**
1320     * Output the trapped error.
1321     */
1322    public function output_trapped_error() {
1323        $this->exit = false; // We're already exiting once.  Don't do it twice.
1324        $this->output(
1325            $this->trapped_error['status'],
1326            (object) array(
1327                'error'   => $this->trapped_error['code'],
1328                'message' => $this->trapped_error['message'],
1329            )
1330        );
1331    }
1332
1333    /**
1334     * Finish the request.
1335     */
1336    public function finish_request() {
1337        if ( function_exists( 'fastcgi_finish_request' ) ) {
1338            return fastcgi_finish_request();
1339        }
1340    }
1341
1342    /**
1343     * Initialize the locale if different from 'en'.
1344     *
1345     * @param string $locale The locale to initialize.
1346     */
1347    public function init_locale( $locale ) {
1348        if ( 'en' !== $locale ) {
1349            // .org mo files are named slightly different from .com, and all we have is this the locale -- try to guess them.
1350            $new_locale = $locale;
1351            if ( str_contains( $locale, '-' ) ) {
1352                $locale_pieces = explode( '-', $locale );
1353                $new_locale    = $locale_pieces[0];
1354                $new_locale   .= ( ! empty( $locale_pieces[1] ) ) ? '_' . strtoupper( $locale_pieces[1] ) : '';
1355            } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
1356                // .com might pass 'fr' because thats what our language files are named as, where core seems
1357                // to do fr_FR - so try that if we don't think we can load the file.
1358                if ( ! file_exists( WP_LANG_DIR . '/' . $locale . '.mo' ) ) {
1359                    $new_locale = $locale . '_' . strtoupper( $locale );
1360                }
1361            }
1362
1363            if ( file_exists( WP_LANG_DIR . '/' . $new_locale . '.mo' ) ) {
1364                unload_textdomain( 'default' );
1365                load_textdomain( 'default', WP_LANG_DIR . '/' . $new_locale . '.mo' );
1366            }
1367        }
1368    }
1369}