PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 15.9-a.1
Jetpack – WP Security, Backup, Speed, & Growth v15.9-a.1
15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / class.json-api.php
jetpack Last commit date
3rd-party 2 months ago _inc 3 weeks ago css 4 weeks ago extensions 3 weeks ago images 1 month ago jetpack_vendor 3 weeks ago json-endpoints 4 weeks ago modules 3 weeks ago sal 4 weeks ago src 4 weeks ago vendor 3 weeks ago views 1 month ago CHANGELOG.md 3 weeks ago LICENSE.txt 5 months ago SECURITY.md 2 years ago class-jetpack-connection-status.php 2 years ago class-jetpack-gallery-settings.php 6 months ago class-jetpack-newsletter-dashboard-widget.php 6 months ago class-jetpack-pre-connection-jitms.php 2 years ago class-jetpack-stats-dashboard-widget.php 3 months ago class-jetpack-xmlrpc-methods.php 6 months ago class.frame-nonce-preview.php 6 months ago class.jetpack-admin.php 1 month ago class.jetpack-autoupdate.php 6 months ago class.jetpack-cli.php 5 months ago class.jetpack-client-server.php 2 years ago class.jetpack-gutenberg.php 2 months ago class.jetpack-heartbeat.php 3 months ago class.jetpack-modules-list-table.php 6 months ago class.jetpack-network-sites-list-table.php 6 months ago class.jetpack-network.php 1 month ago class.jetpack-plan.php 2 years ago class.jetpack-post-images.php 2 months ago class.jetpack-twitter-cards.php 3 months ago class.jetpack-user-agent.php 2 years ago class.jetpack.php 3 weeks ago class.json-api-endpoints.php 1 month ago class.json-api.php 5 months ago class.photon.php 3 years ago composer.json 3 weeks ago enhanced-open-graph.php 3 months ago functions.compat.php 3 months ago functions.cookies.php 2 years ago functions.global.php 1 month ago functions.is-mobile.php 2 years ago functions.opengraph.php 2 months ago functions.photon.php 2 years ago jetpack.php 3 weeks ago json-api-config.php 3 years ago json-endpoints.php 2 years ago load-jetpack.php 2 months ago locales.php 6 months ago readme.txt 3 weeks ago unauth-file-upload.php 6 months ago uninstall.php 6 months ago wpml-config.xml 3 years ago
class.json-api.php
1371 lines
1 <?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2 /**
3 * Jetpack JSON API.
4 *
5 * @package automattic/jetpack
6 */
7
8 use Automattic\Jetpack\Status;
9
10 if ( ! defined( 'WPCOM_JSON_API__DEBUG' ) ) {
11 define( 'WPCOM_JSON_API__DEBUG', false );
12 }
13
14 require_once __DIR__ . '/sal/class.json-api-platform.php';
15
16 /**
17 * Jetpack JSON API.
18 */
19 class 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 $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 // phpcs:ignore Squiz.PHP.DisallowMultipleAssignments.Found -- False positive.
862 $response[ $key_to_filter ]->$key = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
863 } elseif ( is_array( $response[ $key_to_filter ] ) ) {
864 $response[ $key_to_filter ][ $key ] = (object) array_intersect_key( ( (array) $values ), array_flip( $fields ) );
865 }
866 } elseif ( is_array( $values ) ) {
867 $response[ $key_to_filter ][ $key ] = array_intersect_key( $values, array_flip( $fields ) );
868 }
869 }
870
871 $has_filtered = true;
872 }
873 }
874
875 if ( ! $has_filtered ) {
876 if ( is_object( $response ) ) {
877 $response = (object) array_intersect_key( (array) $response, array_flip( $fields ) );
878 } elseif ( is_array( $response ) ) {
879 $response = array_intersect_key( $response, array_flip( $fields ) );
880 }
881 }
882
883 return $response;
884 }
885
886 /**
887 * Filter for `home_url`.
888 *
889 * If `$original_scheme` is null, turns an https URL to http.
890 *
891 * @param string $url The complete home URL including scheme and path.
892 * @param string $path Path relative to the home URL. Blank string if no path is specified.
893 * @param string|null $original_scheme Scheme to give the home URL context. Accepts 'http', 'https', 'relative', 'rest', or null.
894 * @return string URL.
895 */
896 public function ensure_http_scheme_of_home_url( $url, $path, $original_scheme ) {
897 if ( $original_scheme ) {
898 return $url;
899 }
900
901 return preg_replace( '#^https:#', 'http:', $url );
902 }
903
904 /**
905 * Decode HTML special characters in comment content.
906 *
907 * @param string $comment_content Comment content.
908 * @return string
909 */
910 public function comment_edit_pre( $comment_content ) {
911 return htmlspecialchars_decode( $comment_content, ENT_QUOTES );
912 }
913
914 /**
915 * JSON encode.
916 *
917 * @param mixed $value The value to encode.
918 * @param int $flags Options to be passed to json_encode(). Default 0.
919 * @param int $depth Maximum depth to walk through $value. Must be greater than 0.
920 *
921 * @return string|false
922 */
923 public function json_encode( $value, $flags = 0, $depth = 512 ) {
924 return wp_json_encode( $value, $flags, $depth );
925 }
926
927 /**
928 * Test if a string ends with a string.
929 *
930 * @param string $haystack String to check.
931 * @param string $needle Suffix to check.
932 * @return bool
933 */
934 public function ends_with( $haystack, $needle ) {
935 return substr( $haystack, -strlen( $needle ) ) === $needle;
936 }
937
938 /**
939 * Returns the site's blog_id in the WP.com ecosystem
940 *
941 * @return int
942 */
943 public function get_blog_id_for_output() {
944 return $this->token_details['blog_id'];
945 }
946
947 /**
948 * Returns the site's local blog_id.
949 *
950 * @param int $blog_id Blog ID.
951 * @return int
952 */
953 public function get_blog_id( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
954 return $GLOBALS['blog_id'];
955 }
956
957 /**
958 * Switch to blog and validate user.
959 *
960 * @param int $blog_id Blog ID.
961 * @param bool $verify_token_for_blog Whether to verify the token.
962 * @return int Blog ID.
963 */
964 public function switch_to_blog_and_validate_user( $blog_id = 0, $verify_token_for_blog = true ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
965 if ( $this->is_restricted_blog( $blog_id ) ) {
966 return new WP_Error( 'unauthorized', 'User cannot access this restricted blog', 403 );
967 }
968 /**
969 * If this is a private site we check for 2 things:
970 * 1. In case of user based authentication, we need to check if the logged-in user has the 'read' capability.
971 * 2. In case of site based authentication, make sure the endpoint accepts it.
972 */
973 if ( ( new Status() )->is_private_site() &&
974 ! current_user_can( 'read' ) &&
975 ! $this->endpoint->accepts_site_based_authentication()
976 ) {
977 return new WP_Error( 'unauthorized', 'User cannot access this private blog.', 403 );
978 }
979
980 return $blog_id;
981 }
982
983 /**
984 * Switch to a user and blog based on the current request's Jetpack token when the endpoint accepts this feature.
985 *
986 * @return void
987 */
988 protected function maybe_switch_to_token_user_and_site() {
989 if ( ! $this->endpoint->allow_jetpack_token_auth ) {
990 return;
991 }
992
993 if ( ! class_exists( 'Jetpack_Server_Version' ) ) {
994 return;
995 }
996
997 $token = Jetpack_Server_Version::get_token_from_authorization_header();
998
999 if ( ! $token || is_wp_error( $token ) ) {
1000 return;
1001 }
1002
1003 if ( get_current_user_id() !== $token->user_id ) {
1004 wp_set_current_user( $token->user_id );
1005 }
1006
1007 if ( get_current_blog_id() !== $token->blog_id ) {
1008 switch_to_blog( $token->blog_id );
1009 }
1010 }
1011
1012 /**
1013 * Returns true if the specified blog ID is a restricted blog
1014 *
1015 * @param int $blog_id Blog ID.
1016 * @return bool
1017 */
1018 public function is_restricted_blog( $blog_id ) {
1019 /**
1020 * Filters all REST API access and return a 403 unauthorized response for all Restricted blog IDs.
1021 *
1022 * @module json-api
1023 *
1024 * @since 3.4.0
1025 *
1026 * @param array $array Array of Blog IDs.
1027 */
1028 $restricted_blog_ids = apply_filters( 'wpcom_json_api_restricted_blog_ids', array() );
1029 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.
1030 }
1031
1032 /**
1033 * Post like count.
1034 *
1035 * @param int $blog_id Blog ID.
1036 * @param int $post_id Post ID.
1037 * @return int
1038 */
1039 public function post_like_count( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1040 return 0;
1041 }
1042
1043 /**
1044 * Is liked?
1045 *
1046 * @param int $blog_id Blog ID.
1047 * @param int $post_id Post ID.
1048 * @return bool
1049 */
1050 public function is_liked( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1051 return false;
1052 }
1053
1054 /**
1055 * Is reblogged?
1056 *
1057 * @param int $blog_id Blog ID.
1058 * @param int $post_id Post ID.
1059 * @return bool
1060 */
1061 public function is_reblogged( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1062 return false;
1063 }
1064
1065 /**
1066 * Is following?
1067 *
1068 * @param int $blog_id Blog ID.
1069 * @return bool
1070 */
1071 public function is_following( $blog_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
1072 return false;
1073 }
1074
1075 /**
1076 * Add global ID.
1077 *
1078 * @param int $blog_id Blog ID.
1079 * @param int $post_id Post ID.
1080 * @return string
1081 */
1082 public function add_global_ID( $blog_id, $post_id ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable, WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
1083 return '';
1084 }
1085
1086 /**
1087 * Return a count of comment likes.
1088 * This method is overridden by a child class in WPCOM.
1089 *
1090 * @since 13.5
1091 * @return int
1092 */
1093 public function comment_like_count() {
1094 func_get_args(); // @phan-suppress-current-line PhanPluginUseReturnValueInternalKnown -- This is just here so Phan realizes the wpcom version does this.
1095 return 0;
1096 }
1097
1098 /**
1099 * Get avatar URL.
1100 *
1101 * @param string $email Email.
1102 * @param array $args Args for `get_avatar_url()`.
1103 * @return string|false
1104 */
1105 public function get_avatar_url( $email, $args = null ) {
1106 if ( function_exists( 'wpcom_get_avatar_url' ) ) {
1107 $ret = wpcom_get_avatar_url( $email, $args['size'] ?? 96, $args['default'] ?? '', false, $args['force_default'] ?? false );
1108 return $ret ? $ret[0] : false;
1109 } else {
1110 return null === $args
1111 ? get_avatar_url( $email )
1112 : get_avatar_url( $email, $args );
1113 }
1114 }
1115
1116 /**
1117 * Counts the number of comments on a site, including certain comment types.
1118 *
1119 * @param int $post_id Post ID.
1120 * @return object The number of counts keyed by status, matching the output of https://developer.wordpress.org/reference/functions/get_comment_count/.
1121 */
1122 public function wp_count_comments( $post_id ) {
1123 global $wpdb;
1124 if ( 0 !== $post_id ) {
1125 return wp_count_comments( $post_id );
1126 }
1127
1128 $counts = array(
1129 'total_comments' => 0,
1130 'all' => 0,
1131 );
1132
1133 /**
1134 * Exclude certain comment types from comment counts in the REST API.
1135 *
1136 * @since 6.9.0
1137 * @deprecated 11.1
1138 * @module json-api
1139 *
1140 * @param array Array of comment types to exclude (default: 'order_note', 'webhook_delivery', 'review', 'action_log')
1141 */
1142 $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
1143
1144 /**
1145 * Include certain comment types in comment counts in the REST API.
1146 * Note: the default array of comment types includes an empty string,
1147 * to support comments posted before WP 5.5, that used an empty string as comment type.
1148 *
1149 * @since 11.1
1150 * @module json-api
1151 *
1152 * @param array Array of comment types to include (default: 'comment', 'pingback', 'trackback')
1153 */
1154 $include = apply_filters(
1155 'jetpack_api_include_comment_types_count',
1156 array( 'comment', 'pingback', 'trackback', '' )
1157 );
1158
1159 if ( empty( $include ) ) {
1160 return wp_count_comments( $post_id );
1161 }
1162
1163 // The following caching mechanism is based on what the get_comments() function uses.
1164
1165 $key = md5( serialize( $include ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
1166 $last_changed = wp_cache_get_last_changed( 'comment' );
1167
1168 $cache_key = "wp_count_comments:$key:$last_changed";
1169 $count = wp_cache_get( $cache_key, 'jetpack-json-api' );
1170
1171 if ( false === $count ) {
1172 array_walk( $include, 'esc_sql' );
1173 $where = sprintf(
1174 "WHERE comment_type IN ( '%s' )",
1175 implode( "','", $include )
1176 );
1177
1178 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- `$where` is built with escaping just above.
1179 $count = $wpdb->get_results(
1180 "SELECT comment_approved, COUNT(*) AS num_comments
1181 FROM $wpdb->comments
1182 {$where}
1183 GROUP BY comment_approved
1184 "
1185 );
1186 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
1187
1188 wp_cache_add( $cache_key, $count, 'jetpack-json-api' );
1189 }
1190
1191 $approved = array(
1192 '0' => 'moderated',
1193 '1' => 'approved',
1194 'spam' => 'spam',
1195 'trash' => 'trash',
1196 'post-trashed' => 'post-trashed',
1197 );
1198
1199 // <https://developer.wordpress.org/reference/functions/get_comment_count/#source>
1200 foreach ( $count as $row ) {
1201 if ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash', 'spam' ), true ) ) {
1202 $counts['all'] += $row->num_comments;
1203 $counts['total_comments'] += $row->num_comments;
1204 } elseif ( ! in_array( $row->comment_approved, array( 'post-trashed', 'trash' ), true ) ) {
1205 $counts['total_comments'] += $row->num_comments;
1206 }
1207 if ( isset( $approved[ $row->comment_approved ] ) ) {
1208 $counts[ $approved[ $row->comment_approved ] ] = $row->num_comments;
1209 }
1210 }
1211
1212 foreach ( $approved as $key ) {
1213 if ( empty( $counts[ $key ] ) ) {
1214 $counts[ $key ] = 0;
1215 }
1216 }
1217
1218 $counts = (object) $counts;
1219
1220 return $counts;
1221 }
1222
1223 /**
1224 * Traps `wp_die()` calls and outputs a JSON response instead.
1225 * The result is always output, never returned.
1226 *
1227 * @param string|null $error_code Call with string to start the trapping. Call with null to stop.
1228 * @param int $http_status HTTP status code, 400 by default.
1229 */
1230 public function trap_wp_die( $error_code = null, $http_status = 400 ) {
1231 // Determine the filter name; based on the conditionals inside the wp_die function.
1232 if ( wp_is_json_request() ) {
1233 $die_handler = 'wp_die_json_handler';
1234 } elseif ( wp_is_jsonp_request() ) {
1235 $die_handler = 'wp_die_jsonp_handler';
1236 } elseif ( wp_is_xml_request() ) {
1237 $die_handler = 'wp_die_xml_handler';
1238 } else {
1239 $die_handler = 'wp_die_handler';
1240 }
1241
1242 if ( $error_code === null ) {
1243 $this->trapped_error = null;
1244 // Stop trapping.
1245 remove_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1246 return;
1247 }
1248
1249 // If API called via PHP, bail: don't do our custom wp_die(). Do the normal wp_die().
1250 if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
1251 if ( ! defined( 'REST_API_REQUEST' ) || ! REST_API_REQUEST ) {
1252 return;
1253 }
1254 } elseif ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {
1255 return;
1256 }
1257
1258 $this->trapped_error = array(
1259 'status' => $http_status,
1260 'code' => $error_code,
1261 'message' => '',
1262 );
1263 // Start trapping.
1264 add_filter( $die_handler, array( $this, 'wp_die_handler_callback' ) );
1265 }
1266
1267 /**
1268 * Filter function for `wp_die_handler` and similar filters.
1269 *
1270 * @return callable
1271 */
1272 public function wp_die_handler_callback() {
1273 return array( $this, 'wp_die_handler' );
1274 }
1275
1276 /**
1277 * Handler for `wp_die` calls.
1278 *
1279 * @param string|WP_Error $message As for `wp_die()`.
1280 * @param string|int $title As for `wp_die()`.
1281 * @param string|array|int $args As for `wp_die()`.
1282 * @return never
1283 */
1284 public function wp_die_handler( $message, $title = '', $args = array() ) {
1285 // Allow wp_die calls to override HTTP status code...
1286 $args = wp_parse_args(
1287 $args,
1288 array(
1289 'response' => $this->trapped_error['status'],
1290 )
1291 );
1292
1293 // ... unless it's 500
1294 if ( 500 !== (int) $args['response'] ) {
1295 $this->trapped_error['status'] = $args['response'];
1296 }
1297
1298 if ( $title ) {
1299 $message = "$title: $message";
1300 }
1301
1302 $this->trapped_error['message'] = wp_kses( $message, array() );
1303
1304 switch ( $this->trapped_error['code'] ) {
1305 case 'comment_failure':
1306 if ( did_action( 'comment_duplicate_trigger' ) ) {
1307 $this->trapped_error['code'] = 'comment_duplicate';
1308 } elseif ( did_action( 'comment_flood_trigger' ) ) {
1309 $this->trapped_error['code'] = 'comment_flood';
1310 }
1311 break;
1312 }
1313
1314 // We still want to exit so that code execution stops where it should.
1315 // Attach the JSON output to the WordPress shutdown handler.
1316 add_action( 'shutdown', array( $this, 'output_trapped_error' ), 0 );
1317 exit( 0 );
1318 }
1319
1320 /**
1321 * Output the trapped error.
1322 */
1323 public function output_trapped_error() {
1324 $this->exit = false; // We're already exiting once. Don't do it twice.
1325 $this->output(
1326 $this->trapped_error['status'],
1327 (object) array(
1328 'error' => $this->trapped_error['code'],
1329 'message' => $this->trapped_error['message'],
1330 )
1331 );
1332 }
1333
1334 /**
1335 * Finish the request.
1336 */
1337 public function finish_request() {
1338 if ( function_exists( 'fastcgi_finish_request' ) ) {
1339 return fastcgi_finish_request();
1340 }
1341 }
1342
1343 /**
1344 * Initialize the locale if different from 'en'.
1345 *
1346 * @param string $locale The locale to initialize.
1347 */
1348 public function init_locale( $locale ) {
1349 if ( 'en' !== $locale ) {
1350 // .org mo files are named slightly different from .com, and all we have is this the locale -- try to guess them.
1351 $new_locale = $locale;
1352 if ( str_contains( $locale, '-' ) ) {
1353 $locale_pieces = explode( '-', $locale );
1354 $new_locale = $locale_pieces[0];
1355 $new_locale .= ( ! empty( $locale_pieces[1] ) ) ? '_' . strtoupper( $locale_pieces[1] ) : '';
1356 } else { // phpcs:ignore Universal.ControlStructures.DisallowLonelyIf.Found
1357 // .com might pass 'fr' because thats what our language files are named as, where core seems
1358 // to do fr_FR - so try that if we don't think we can load the file.
1359 if ( ! file_exists( WP_LANG_DIR . '/' . $locale . '.mo' ) ) {
1360 $new_locale = $locale . '_' . strtoupper( $locale );
1361 }
1362 }
1363
1364 if ( file_exists( WP_LANG_DIR . '/' . $new_locale . '.mo' ) ) {
1365 unload_textdomain( 'default' );
1366 load_textdomain( 'default', WP_LANG_DIR . '/' . $new_locale . '.mo' );
1367 }
1368 }
1369 }
1370 }
1371