449 lines
15 KiB
PHP
449 lines
15 KiB
PHP
<?php
|
||
/**
|
||
* Class AMP_HTTP
|
||
*
|
||
* @since 1.0
|
||
* @package AMP
|
||
*/
|
||
|
||
/**
|
||
* Class AMP_HTTP
|
||
*/
|
||
class AMP_HTTP {
|
||
|
||
/**
|
||
* Headers sent (or attempted to be sent).
|
||
*
|
||
* @since 1.0
|
||
* @see AMP_HTTP::send_header()
|
||
* @var array[]
|
||
*/
|
||
public static $headers_sent = array();
|
||
|
||
/**
|
||
* 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 = array();
|
||
|
||
/**
|
||
* 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 = array() ) {
|
||
$args = array_merge(
|
||
array(
|
||
'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 ( ! WP_DEBUG && ! current_user_can( 'manage_options' ) ) {
|
||
return false;
|
||
}
|
||
$value = $name;
|
||
if ( isset( $description ) ) {
|
||
$value .= sprintf( ';desc="%s"', str_replace( array( '\\', '"' ), '', 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, array( '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 = array(
|
||
'__amp_source_origin',
|
||
'_wp_amp_action_xhr_converted',
|
||
'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 = function ( $query ) use ( $query_vars ) {
|
||
$pattern = '/^(' . join( '|', $query_vars ) . ')(?==|$)/';
|
||
$pairs = array();
|
||
foreach ( explode( '&', $query ) as $pair ) {
|
||
if ( ! preg_match( $pattern, $pair ) ) {
|
||
$pairs[] = $pair;
|
||
}
|
||
}
|
||
|
||
return join( '&', $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 = array();
|
||
|
||
// Google AMP Cache (legacy).
|
||
$hosts[] = 'cdn.ampproject.org';
|
||
|
||
// From the publisher’s own origins.
|
||
$domains = array_unique(
|
||
array(
|
||
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 );
|
||
$subdomain = str_replace( '.', '-', $subdomain );
|
||
|
||
// Google AMP Cache subdomain.
|
||
$hosts[] = sprintf( '%s.cdn.ampproject.org', $subdomain );
|
||
|
||
// Cloudflare AMP Cache.
|
||
$hosts[] = sprintf( '%s.amp.cloudflare.com', $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, array( 'replace' => false ) );
|
||
self::send_header( 'Access-Control-Allow-Credentials', 'true' );
|
||
self::send_header( 'Vary', 'Origin', array( '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', array( '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['_wp_amp_action_xhr_converted'] )
|
||
&&
|
||
( ! empty( $_SERVER['REQUEST_METHOD'] ) && 'POST' === $_SERVER['REQUEST_METHOD'] )
|
||
);
|
||
if ( ! $is_amp_xhr ) {
|
||
return;
|
||
}
|
||
|
||
// Intercept POST requests which redirect.
|
||
add_filter( 'wp_redirect', array( __CLASS__, 'intercept_post_request_redirect' ), PHP_INT_MAX );
|
||
|
||
// Add special handling for redirecting after comment submission.
|
||
add_filter( 'comment_post_redirect', array( __CLASS__, 'filter_comment_post_redirect' ), PHP_INT_MAX, 2 );
|
||
|
||
// Add die handler for AMP error display, most likely due to problem with comment.
|
||
add_filter(
|
||
'wp_die_handler',
|
||
function () {
|
||
return array( __CLASS__, 'handle_wp_die' );
|
||
}
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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(
|
||
array(
|
||
'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', array( 'replace' => false ) );
|
||
|
||
wp_send_json_success();
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* }
|
||
*/
|
||
public static function handle_wp_die( $error, $title = '', $args = array() ) {
|
||
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;
|
||
}
|
||
status_header( $status_code );
|
||
|
||
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(
|
||
array(
|
||
'error' => amp_wp_kses_mustache( $error ),
|
||
)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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(
|
||
array(
|
||
'message' => amp_wp_kses_mustache( $message ),
|
||
)
|
||
);
|
||
|
||
return null;
|
||
}
|
||
}
|