You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

510 lines
17 KiB

<?php
/**
* Class AMP_HTTP
*
* @since 1.0
* @package AMP
*/
/**
* Class AMP_HTTP
*/
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
*
* @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 ) {
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 whitelist sanitizer empties out links that contain this string as it matches the
* blacklisted_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
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'] );
}
// Scrub REQUEST_URI.
if ( ! empty( $_SERVER['REQUEST_URI'] ) ) {
list( $path, $query ) = explode( '?', $_SERVER['REQUEST_URI'], 2 );
$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 ),
]
);
/*
* 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' ) ) {
// The third parameter is set explicitly to prevent issues with newer PHP versions compiled with an old ICU version.
// phpcs:ignore PHPCompatibility.Constants.RemovedConstants.intl_idna_variant_2003Deprecated
$domain = idn_to_utf8( $domain, IDNA_DEFAULT, defined( 'INTL_IDNA_VARIANT_UTS46' ) ? INTL_IDNA_VARIANT_UTS46 : INTL_IDNA_VARIANT_2003 );
}
$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()
*
* @param string $location The location to redirect to.
*/
public static function intercept_post_request_redirect( $location ) {
// Make sure relative redirects get made absolute.
$parsed_location = array_merge(
[
'scheme' => 'https',
'host' => wp_parse_url( home_url(), PHP_URL_HOST ),
'path' => isset( $_SERVER['REQUEST_URI'] ) ? strtok( wp_unslash( $_SERVER['REQUEST_URI'] ), '?' ) : '/',
],
wp_parse_url( $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;
}
}