-
Notifications
You must be signed in to change notification settings - Fork 385
/
class-amp-http.php
533 lines (483 loc) · 18.4 KB
/
class-amp-http.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
<?php
/**
* Class AMP_HTTP
*
* @since 1.0
* @package AMP
*/
/**
* Class AMP_HTTP
*
* @internal
*/
class AMP_HTTP {
/**
* Query var which is submitted with a form which had an action attribute which was automatically converted into action-xhr.
*
* @see \AMP_Form_Sanitizer::sanitize()
* @var string
*/
const ACTION_XHR_CONVERTED_QUERY_VAR = '_wp_amp_action_xhr_converted';
/**
* Headers sent (or attempted to be sent).
*
* This is used primarily for the benefit of unit testing. Otherwise, `headers_list()` should be used.
*
* @since 1.0
* @see AMP_HTTP::send_header()
* @var array[]
*/
public static $headers_sent = [];
/**
* Whether Server-Timing headers are sent.
*
* By default this is false to prevent breaking some web servers with an unexpected number of response headers. To
* enable in `WP_DEBUG` mode, consider the following plugin code:
*
* add_action( 'amp_init', function () {
* AMP_HTTP::$server_timing = ( ( defined( 'WP_DEBUG' ) && WP_DEBUG ) || current_user_can( 'manage_options' ) );
* } );
*
* @link https://gist.github.com/westonruter/053f8f47c21df51f1a081fc41b47f547
* @var bool
*/
public static $server_timing = false;
/**
* AMP-specific query vars that were purged.
*
* @since 0.7
* @since 1.0 Moved to AMP_HTTP class.
* @see AMP_HTTP::purge_amp_query_vars()
* @var string[]
*/
public static $purged_amp_query_vars = [];
/**
* Send an HTTP response header.
*
* This largely exists to facilitate unit testing but it also provides a better interface for sending headers.
*
* @since 0.7.0
* @since 1.0 Moved to AMP_HTTP class.
*
* @param string $name Header name.
* @param string $value Header value.
* @param array $args {
* Args to header().
*
* @type bool $replace Whether to replace a header previously sent. Default true.
* @type int $status_code Status code to send with the sent header.
* }
* @return bool Whether the header was sent.
*/
public static function send_header( $name, $value, $args = [] ) {
$args = array_merge(
[
'replace' => true,
'status_code' => null,
],
$args
);
self::$headers_sent[] = array_merge( compact( 'name', 'value' ), $args );
if ( headers_sent() ) {
return false;
}
header(
sprintf( '%s: %s', $name, $value ),
$args['replace'],
$args['status_code']
);
return true;
}
/**
* Send Server-Timing header.
*
* If WP_DEBUG is not enabled and an admin user (who can manage_options) is not logged-in, the Server-Header will not be sent.
*
* @since 1.0
*
* @deprecated Use the `ServerTiming` service or its associated actions instead.
* @internal
*
* @param string $name Name.
* @param float $duration Duration. If negative, will be added to microtime( true ). Optional.
* @param string $description Description. Optional.
* @return bool Return value of send_header call. If WP_DEBUG is not enabled or admin user (who can manage_options) is not logged-in, this will always return false.
*/
public static function send_server_timing( $name, $duration = null, $description = null ) {
_deprecated_function( __METHOD__, '2.0.0', 'Use the AmpProject\AmpWp\Instrumentation\ServerTiming service or its associated actions instead.' );
if ( ! self::$server_timing ) {
return false;
}
$value = $name;
if ( isset( $description ) ) {
$value .= sprintf( ';desc="%s"', str_replace( [ '\\', '"' ], '', substr( $description, 0, 100 ) ) );
}
if ( isset( $duration ) ) {
if ( $duration < 0 ) {
$duration = microtime( true ) + $duration;
}
$value .= sprintf( ';dur=%f', $duration * 1000 );
}
return self::send_header( 'Server-Timing', $value, [ 'replace' => false ] );
}
/**
* Remove query vars that come in requests such as for amp-live-list.
*
* WordPress should generally not respond differently to requests when these parameters
* are present. In some cases, when a query param such as __amp_source_origin is present
* then it would normally get included into pagination links generated by get_pagenum_link().
* The validating sanitizer empties out links that contain this string as it matches the
* disallowed_value_regex. So by preemptively scrubbing any reference to these query vars
* we can ensure that WordPress won't end up referencing them in any way.
*
* @since 0.7
* @since 1.0 Moved to AMP_HTTP class.
*/
public static function purge_amp_query_vars() {
$query_vars = [
'__amp_source_origin',
self::ACTION_XHR_CONVERTED_QUERY_VAR,
'amp_latest_update_time',
'amp_last_check_time',
];
// Scrub input vars.
foreach ( $query_vars as $query_var ) {
if ( ! isset( $_GET[ $query_var ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
continue;
}
self::$purged_amp_query_vars[ $query_var ] = wp_unslash( $_GET[ $query_var ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
unset( $_REQUEST[ $query_var ], $_GET[ $query_var ] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
$scrubbed = true;
}
if ( isset( $scrubbed ) ) {
$build_query = static function ( $query ) use ( $query_vars ) {
$pattern = '/^(' . implode( '|', $query_vars ) . ')(?==|$)/';
$pairs = [];
foreach ( explode( '&', $query ) as $pair ) {
if ( ! preg_match( $pattern, $pair ) ) {
$pairs[] = $pair;
}
}
return implode( '&', $pairs );
};
// Scrub QUERY_STRING.
if ( ! empty( $_SERVER['QUERY_STRING'] ) ) {
$_SERVER['QUERY_STRING'] = $build_query( $_SERVER['QUERY_STRING'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
}
// Scrub REQUEST_URI.
if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
list( $path, $query ) = explode( '?', $_SERVER['REQUEST_URI'], 2 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
$pairs = $build_query( $query );
$_SERVER['REQUEST_URI'] = $path;
if ( ! empty( $pairs ) ) {
$_SERVER['REQUEST_URI'] .= "?{$pairs}";
}
}
}
}
/**
* Filter the allowed redirect hosts to include AMP caches.
*
* @since 1.0
*
* @param array $allowed_hosts Allowed hosts.
* @return array Allowed redirect hosts.
*/
public static function filter_allowed_redirect_hosts( $allowed_hosts ) {
return array_merge( $allowed_hosts, self::get_amp_cache_hosts() );
}
/**
* Get list of AMP cache hosts (that is, CORS origins).
*
* @since 1.0
* @link https://www.ampproject.org/docs/fundamentals/amp-cors-requests#1)-allow-requests-for-specific-cors-origins
*
* @return array AMP cache hosts.
*/
public static function get_amp_cache_hosts() {
$hosts = [];
// Google AMP Cache (legacy).
$hosts[] = 'cdn.ampproject.org';
// From the publisher’s own origins.
$domains = array_unique(
[
wp_parse_url( site_url(), PHP_URL_HOST ),
wp_parse_url( home_url(), PHP_URL_HOST ),
]
);
if ( defined( 'INTL_IDNA_VARIANT_UTS46' ) ) {
$intl_idna_variant = INTL_IDNA_VARIANT_UTS46;
} elseif ( defined( 'INTL_IDNA_VARIANT_2003' ) ) {
$intl_idna_variant = INTL_IDNA_VARIANT_2003; // phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003Deprecated
} else {
$intl_idna_variant = 0;
}
/*
* From AMP docs:
* "When possible, the Google AMP Cache will create a subdomain for each AMP document's domain by first converting it
* from IDN (punycode) to UTF-8. The caches replaces every - (dash) with -- (2 dashes) and replace every . (dot) with
* - (dash). For example, pub.com will map to pub-com.cdn.ampproject.org."
*/
foreach ( $domains as $domain ) {
if ( function_exists( 'idn_to_utf8' ) && $intl_idna_variant ) {
// The third parameter is set explicitly to prevent issues with newer PHP versions compiled with an old ICU version.
$domain = idn_to_utf8( $domain, IDNA_DEFAULT, $intl_idna_variant );
}
$subdomain = str_replace( [ '-', '.' ], [ '--', '-' ], $domain );
// Google AMP Cache subdomain.
$hosts[] = sprintf( '%s.cdn.ampproject.org', $subdomain );
// Bing AMP Cache.
$hosts[] = sprintf( '%s.bing-amp.com', $subdomain );
}
return $hosts;
}
/**
* Send cors headers.
*
* From the AMP docs:
* Restrict requests to source origins
* In all fetch requests, the AMP Runtime passes the "__amp_source_origin" query parameter, which contains
* the value of the source origin (for example, "https://publisher1.com").
*
* To restrict requests to only source origins, check that the value of the "__amp_source_origin" parameter
* is within a set of the Publisher's own origins.
*
* Access-Control-Allow-Origin: <origin>
* This header is a W3 CORS Spec requirement, where origin refers to the requesting origin that was allowed
* via the CORS Origin request header (for example, "https://<publisher's subdomain>.cdn.ampproject.org").
*
* Although the W3 CORS spec allows the value of * to be returned in the response, for improved security, you should:
*
* - If the Origin header is present, validate and echo the value of the Origin header.
* - If the Origin header isn't present, validate and echo the value of the "__amp_source_origin".
*
* (Otherwise, no Access-Control-Allow-Origin header is sent.)
*
* AMP-Access-Control-Allow-Source-Origin: <source-origin>
* This header allows the specified source-origin to read the authorization response. The source-origin is
* the value specified and verified in the "__amp_source_origin" URL parameter (for example, "https://publisher1.com").
*
* Access-Control-Expose-Headers: AMP-Access-Control-Allow-Source-Origin
* This header simply allows the CORS response to contain the AMP-Access-Control-Allow-Source-Origin header.
*
* @link https://www.ampproject.org/docs/fundamentals/amp-cors-requests
* @since 1.0
*/
public static function send_cors_headers() {
$origin = null;
$source_origin = null;
if ( isset( $_SERVER['HTTP_ORIGIN'] ) ) {
$origin = wp_validate_redirect( wp_sanitize_redirect( esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) ) );
}
if ( isset( self::$purged_amp_query_vars['__amp_source_origin'] ) ) {
$source_origin = wp_validate_redirect( wp_sanitize_redirect( esc_url_raw( self::$purged_amp_query_vars['__amp_source_origin'] ) ) );
}
if ( ! $origin ) {
$origin = $source_origin;
}
if ( $origin ) {
self::send_header( 'Access-Control-Allow-Origin', $origin, [ 'replace' => false ] );
self::send_header( 'Access-Control-Allow-Credentials', 'true' );
self::send_header( 'Vary', 'Origin', [ 'replace' => false ] );
}
if ( $source_origin ) {
self::send_header( 'AMP-Access-Control-Allow-Source-Origin', $source_origin );
self::send_header( 'Access-Control-Expose-Headers', 'AMP-Access-Control-Allow-Source-Origin', [ 'replace' => false ] );
}
}
/**
* Hook into a POST form submissions, such as the comment form or some other form submission.
*
* @since 0.7.0
* @since 1.0 Moved to AMP_HTTP class. Extracted some logic to send_cors_headers method.
*/
public static function handle_xhr_request() {
$is_amp_xhr = (
! empty( self::$purged_amp_query_vars[ self::ACTION_XHR_CONVERTED_QUERY_VAR ] )
&&
( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] )
);
if ( ! $is_amp_xhr ) {
return;
}
// Intercept POST requests which redirect.
add_filter( 'wp_redirect', [ __CLASS__, 'intercept_post_request_redirect' ], PHP_INT_MAX );
// Add special handling for redirecting after comment submission.
add_filter( 'comment_post_redirect', [ __CLASS__, 'filter_comment_post_redirect' ], PHP_INT_MAX, 2 );
// Add die handler for AMP error display, most likely due to problem with comment.
$handle_wp_die = static function () {
return [ __CLASS__, 'handle_wp_die' ];
};
add_filter( 'wp_die_json_handler', $handle_wp_die );
add_filter( 'wp_die_handler', $handle_wp_die ); // Needed for WP<5.1.
}
/**
* Intercept the response to a POST request.
*
* @since 0.7.0
* @since 1.0 Moved to AMP_HTTP class.
* @see wp_redirect()
* @see amp_get_current_url()
* @see AMP_Form_Sanitizer::get_action_url()
*
* @param string $location The location to redirect to.
*/
public static function intercept_post_request_redirect( $location ) { // phpcs:ignore WordPressVIPMinimum.Hooks.AlwaysReturnInFilter.MissingReturnStatement -- It dies.
// Make sure relative redirects get made absolute.
$parsed_home_url = wp_parse_url( get_home_url() );
$parsed_location = wp_parse_url( $location );
if ( isset( $parsed_location['host'] ) ) {
// Make sure the home port is not accidentally applied to the redirect location URL.
unset( $parsed_home_url['port'] );
}
$parsed_location = array_merge(
wp_array_slice_assoc( $parsed_home_url, [ 'host', 'port' ] ),
[
'scheme' => 'https',
'path' => isset( $_SERVER['REQUEST_URI'] ) ? strtok( wp_unslash( $_SERVER['REQUEST_URI'] ), '?' ) : '/', // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
],
$parsed_location
);
$absolute_location = '';
if ( 'https' === $parsed_location['scheme'] ) {
$absolute_location .= $parsed_location['scheme'] . ':';
}
$absolute_location .= '//' . $parsed_location['host'];
if ( isset( $parsed_location['port'] ) ) {
$absolute_location .= ':' . $parsed_location['port'];
}
$absolute_location .= $parsed_location['path'];
if ( isset( $parsed_location['query'] ) ) {
$absolute_location .= '?' . $parsed_location['query'];
}
if ( isset( $parsed_location['fragment'] ) ) {
$absolute_location .= '#' . $parsed_location['fragment'];
}
self::send_header( 'AMP-Redirect-To', $absolute_location );
self::send_header( 'Access-Control-Expose-Headers', 'AMP-Redirect-To', [ 'replace' => false ] );
wp_send_json(
[
'message' => __( 'Redirecting…', 'amp' ),
'redirecting' => true, // Make sure that the submit-success doesn't get styled as success since redirection _could_ be to error page.
],
200
);
}
/**
* New error handler for AMP form submission.
*
* @since 0.7.0
* @since 1.0 Moved to AMP_HTTP class.
* @see wp_die()
*
* @param WP_Error|string $error The error to handle.
* @param string|int $title Optional. Error title. If `$message` is a `WP_Error` object,
* error data with the key 'title' may be used to specify the title.
* If `$title` is an integer, then it is treated as the response
* code. Default empty.
* @param string|array|int $args {
* Optional. Arguments to control behavior. If `$args` is an integer, then it is treated
* as the response code. Default empty array.
*
* @type int $response The HTTP response code. Default 200 for Ajax requests, 500 otherwise.
* }
* @global string $pagenow
*/
public static function handle_wp_die( $error, $title = '', $args = [] ) {
global $pagenow;
if ( is_int( $title ) ) {
$status_code = $title;
} elseif ( is_int( $args ) ) {
$status_code = $args;
} elseif ( is_array( $args ) && isset( $args['response'] ) ) {
$status_code = $args['response'];
} else {
$status_code = 500;
}
/*
* Handle apparent defect in core where invalid comment form submissions return with a 200 status code.
* Successful requests to wp-comments-post.php should always end up doing a redirect after applying the
* comment_post_redirect filter, and as such the \AMP_HTTP::filter_comment_post_redirect() method will
* ensure that redirect works in AMP. When there is no comment_post_redirect then the alternative is a wp_die()
* scenario which should always be considered an error. This workaround is important because otherwise an error
* case will get rendered unexpectedly in the div[submit-success] element, when it should be rendered in the
* div[submit-error] element. For a fix to the core defect which will make this unnecessary,
* see <https://core.trac.wordpress.org/ticket/47393>.
*/
if ( 200 === $status_code && isset( $pagenow ) && 'wp-comments-post.php' === $pagenow ) {
$status_code = 400;
}
if ( is_wp_error( $error ) ) {
$error = $error->get_error_message();
}
// Message will be shown in template defined by AMP_Theme_Support::amend_comment_form().
wp_send_json(
[
'message' => amp_wp_kses_mustache( $error ),
],
$status_code
);
}
/**
* Handle comment_post_redirect to ensure page reload is done when comments_live_list is not supported, while sending back a success message when it is.
*
* @since 0.7.0
* @since 1.0 Moved to AMP_HTTP class.
*
* @param string $url Comment permalink to redirect to.
* @param WP_Comment $comment Posted comment.
*
* @return string|null URL if redirect to be done; otherwise function will exist.
*/
public static function filter_comment_post_redirect( $url, $comment ) {
$theme_support = AMP_Theme_Support::get_theme_support_args();
// Cause a page refresh if amp-live-list is not implemented for comments via add_theme_support( AMP_Theme_Support::SLUG, array( 'comments_live_list' => true ) ).
if ( empty( $theme_support['comments_live_list'] ) ) {
/*
* Add the comment ID to the URL to force AMP to refresh the page.
* This is ideally a temporary workaround to deal with https://github.com/ampproject/amphtml/issues/14170
*/
$url = add_query_arg( 'comment', $comment->comment_ID, $url );
// Pass URL along to wp_redirect().
return $url;
}
// Create a success message to display to the user.
if ( '1' === (string) $comment->comment_approved ) {
$message = __( 'Your comment has been posted.', 'amp' );
} else {
$message = __( 'Your comment is awaiting moderation.', 'amp' );
}
/**
* Filters the message when comment submitted success message when
*
* @since 0.7
*/
$message = apply_filters( 'amp_comment_posted_message', $message, $comment );
// Message will be shown in template defined by AMP_Theme_Support::amend_comment_form().
wp_send_json(
[
'message' => amp_wp_kses_mustache( $message ),
],
200
);
return null;
}
/**
* Get the Content-Type for the response.
*
* @since 1.2
*
* @return string Content type.
*/
public static function get_response_content_type() {
$content_type = ini_get( 'default_mimetype' );
foreach ( headers_list() as $header ) {
list( $name, $value ) = explode( ':', $header, 2 );
if ( 'content-type' === strtolower( $name ) ) {
$content_type = trim( $value );
break;
}
}
return $content_type;
}
}