Skip to content

Instantly share code, notes, and snippets.

@westonruter
Last active March 15, 2022 21:20
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save westonruter/c08134bf8b33a49ef780a70b337d456f to your computer and use it in GitHub Desktop.
Save westonruter/c08134bf8b33a49ef780a70b337d456f to your computer and use it in GitHub Desktop.
<?php
/**
* AMP Form Submission Proxy plugin bootstrap.
*
* @package Google\AMP_Form_Submission_Proxy
* @author Weston Ruter, Google
* @license GPL-2.0-or-later
* @copyright 2021 Google Inc.
*
* @wordpress-plugin
* Plugin Name: AMP Form Submission Proxy
* Plugin URI: https://gist.github.com/westonruter/c08134bf8b33a49ef780a70b337d456f
* Description: Prototype to implement <a href="https://github.com/ampproject/amp-wp/issues/4191">amp-wp#4191</a> specifically to proxy external form submissions, such as to Mailchimp or Genesis eNews Extended.
* Version: 0.2.1
* Author: Weston Ruter, Google
* Author URI: https://weston.ruter.net/
* License: GNU General Public License v2 (or later)
* License URI: http://www.gnu.org/licenses/gpl-2.0.html
* Gist Plugin URI: https://gist.github.com/westonruter/c08134bf8b33a49ef780a70b337d456f
* Update URI: https://gist.github.com/westonruter/c08134bf8b33a49ef780a70b337d456f
*/
namespace Google\AMP_Form_Submission_Proxy;
use function register_rest_route;
use WP_REST_Server;
use WP_REST_Request;
const URL_SAFE_BASE64_CHAR_REPLACEMENTS = [
'+/=',
'._-',
];
const REST_API_NAMESPACE = 'amp-form-submission-proxy/v1';
const REST_API_ROUTE = '/proxy';
add_filter(
'amp_content_sanitizers',
static function ( $sanitizers ) {
require_once __DIR__ . '/FormActionRewriteSanitizer.php';
$sanitizers[ FormActionRewriteSanitizer::class ] = [
'parsed_home_url' => wp_parse_url( home_url( '/' ) ),
];
return $sanitizers;
}
);
/**
* URL-safe base64-encode.
*
* @see https://stackoverflow.com/a/5835352/93579
* @param string $input Input.
* @return string
*/
function url_safe_base64_encode( $input ) {
return strtr( base64_encode( $input ), URL_SAFE_BASE64_CHAR_REPLACEMENTS[0], URL_SAFE_BASE64_CHAR_REPLACEMENTS[1] );
}
/**
* URL-safe base64-decode.
*
* @see https://stackoverflow.com/a/5835352/93579
* @param string $input Input.
* @return string
*/
function url_safe_base64_decode( $input ) {
return base64_decode( strtr( $input, URL_SAFE_BASE64_CHAR_REPLACEMENTS[1], URL_SAFE_BASE64_CHAR_REPLACEMENTS[0] ) );
}
/**
* Get REST route.
*
* @param string $url URL.
*
* @return string
*/
function get_proxy_rest_route( $url ) {
return rest_url(
sprintf(
'/%s%s/%s/%s',
REST_API_NAMESPACE,
REST_API_ROUTE,
urlencode( wp_hash( $url, 'nonce' ) ),
urlencode( url_safe_base64_encode( $url ) )
)
);
}
add_action(
'rest_api_init',
static function () {
register_rest_route(
REST_API_NAMESPACE,
REST_API_ROUTE . '/(?P<url_hmac>[0-9a-f]{32})/(?P<base64_url>[a-zA-Z0-9._-]+)',
[
'methods' => WP_REST_Server::CREATABLE,
'permission_callback' => '__return_true',
'callback' => static function ( WP_REST_Request $request ) {
require_once __DIR__ . '/Proxy.php';
$proxy = new Proxy( $request );
return $proxy->submit();
},
]
);
}
);
<?php
/**
* FormActionRewriteSanitizer file.
*
* @package Google\AMP_Form_Submission_Proxy
*/
namespace Google\AMP_Form_Submission_Proxy;
use AMP_Base_Sanitizer;
use AmpProject\Dom\Element;
/**
* Class Sanitizer
*/
class FormActionRewriteSanitizer extends AMP_Base_Sanitizer {
/** @var array[] */
protected $DEFAULT_ARGS = [
'parsed_home_url' => [],
'rest_route' => '',
];
/**
* Sanitize.
*/
public function sanitize() {
if ( empty( $this->args['parsed_home_url']['host'] ) || $this->args['rest_route'] ) {
return;
}
$post_forms = $this->dom->xpath->query( '//form[ @action-xhr and @method = "post" ]' );
foreach ( $post_forms as $post_form ) {
$this->convert_form( $post_form );
}
}
/**
* Convert form.
*
* @param Element $post_form Post form.
*/
private function convert_form( Element $post_form ) {
/** @var Element $post_form */
$action_url = $post_form->getAttribute( 'action-xhr' );
// Make sure that the form sanitizer converted this non-AMP form.
$parsed_action = wp_parse_url( $action_url );
if ( empty( $parsed_action ) || empty( $parsed_action['host'] ) || empty( $parsed_action['query'] ) ) {
return;
}
$query_vars = [];
parse_str( $parsed_action['query'], $query_vars );
if ( empty( $query_vars['_wp_amp_action_xhr_converted'] ) ) {
return;
}
// Skip internal form submissions for now.
if ( $this->args['parsed_home_url']['host'] === $parsed_action['host'] ) {
return;
}
$action_url = remove_query_arg( '_wp_amp_action_xhr_converted', $action_url );
$post_form->setAttribute( 'action-xhr', get_proxy_rest_route( $action_url ) );
// Prevent amp-form from failing to redirect with an error:
// > Redirecting to target=_blank using AMP-Redirect-To is currently not supported, use target=_top instead.
$post_form->setAttribute( 'target', '_top' );
}
}
<?php
/**
* Proxy file.
*
* @package Google\AMP_Form_Submission_Proxy
*/
namespace Google\AMP_Form_Submission_Proxy;
use WP_REST_Response;
use WP_REST_Request;
use Exception;
use AmpProject\Url;
use DOMDocument;
use DOMXPath;
use DOMElement;
// phpcs:disable WordPress.WP.I18n.TextDomainMismatch
/**
* Class Sanitizer
*/
class Proxy {
/** @var WP_REST_Request */
private $request;
/** @var string|null */
private $origin;
/**
* Proxy constructor.
*
* @param WP_REST_Request $request Request.
*/
public function __construct( WP_REST_Request $request ) {
$this->request = $request;
$this->origin = $request->get_header( 'origin' );
}
/**
* Submit.
*
* @return WP_REST_Response
*/
public function submit() {
if ( empty( $this->origin ) || ! wp_validate_redirect( $this->origin ) ) {
return new WP_REST_Response(
[
'message' => __( 'Missing or invalid <code>Origin</code> request header.', 'amp-form-submission-proxy' ),
],
400,
[ 'Access-Control-Allow-Origin' => '*' ]
);
}
$url = url_safe_base64_decode( urldecode( $this->request->get_param( 'base64_url' ) ) );
$parsed_url = wp_parse_url( $url );
if ( empty( $parsed_url ) || empty( $parsed_url['host'] ) ) {
return $this->create_error_response( __( 'Form action URL parse error.', 'amp-form-submission-proxy' ), 400 );
}
if ( wp_parse_url( home_url( '/' ), PHP_URL_HOST ) === $parsed_url['host'] ) {
return $this->create_error_response( __( 'Cannot submit to self.', 'amp-form-submission-proxy' ), 400 );
}
$expected_hmac = wp_hash( $url, 'nonce' );
if ( ! hash_equals( $expected_hmac, $this->request->get_param( 'url_hmac' ) ) ) {
return $this->create_error_response( __( 'HMAC mismatch.', 'amp-form-submission-proxy' ), 400 );
}
$headers = $this->request->get_headers();
// Remove irrelevant headers.
unset(
$headers['cookie'],
$headers['host'],
$headers['content_length'],
$headers['content_type'],
$headers['amp_same_origin']
);
$forwarded_headers = [];
foreach ( $headers as $header_name => $header_value ) {
// This is the reverse of \WP_REST_Request::canonicalize_header_name().
$header_name = str_replace( '_', '-', $header_name );
if ( is_array( $header_value ) ) {
$header_value = implode( ', ', $header_value );
}
$forwarded_headers[ $header_name ] = $header_value;
}
$response = wp_remote_post(
$url,
[
'timeout' => 30,
'headers' => $forwarded_headers,
'body' => $this->request->get_body_params(),
'redirection' => 0,
]
);
if ( is_wp_error( $response ) ) {
return $this->create_error_response(
__( 'Submission error: ', 'amp-form-submission-proxy' ) . $response->get_error_message(),
500
);
}
$message = null;
$status_code = wp_remote_retrieve_response_code( $response );
$status_text = wp_remote_retrieve_response_message( $response );
try {
// @todo It appears that relative URLs may be made absolute with the wrong base URL.
$location = wp_remote_retrieve_header( $response, 'location' );
if ( $status_code >= 300 && $status_code < 400 && $location ) {
$redirect_url = new Url( $location, new Url( $url ) );
return new WP_REST_Response(
[
'redirecting' => true,
'message' => __( 'Redirecting…', 'amp-form-submission-proxy' ),
],
200,
[
'AMP-Redirect-To' => (string) $redirect_url,
'Access-Control-Expose-Headers' => 'AMP-Redirect-To',
'Access-Control-Allow-Origin' => $this->origin,
]
);
}
} catch ( Exception $exception ) {
$this->create_error_response(
__( 'An exception occurred while attempting to redirect.', 'amp-form-submission-proxy' ),
500
);
}
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
$body = wp_remote_retrieve_body( $response );
if ( 0 === strpos( $content_type, 'application/json' ) ) {
$body = json_decode( $body, true );
if ( isset( $body['message'] ) && is_string( $body['message'] ) ) {
$message = $body['message'];
}
} elseif ( 0 === strpos( $content_type, 'text/html' ) ) {
$libxml_previous_state = libxml_use_internal_errors( true );
$dom = new DOMDocument();
$dom->loadHTML( $body );
$xpath = new DOMXPath( $dom );
libxml_clear_errors();
libxml_use_internal_errors( $libxml_previous_state );
// Add special handling for Mailchimp success/failure page since redirection is not performed.
if ( preg_match( '/\.list-manage\.com$/', wp_parse_url( $url, PHP_URL_HOST ) ) ) {
$error_texts = $xpath->query( '//div[ @class = "errorText" ]' );
$template_body = $dom->getElementById( 'templateBody' );
if ( $error_texts->length > 0 ) {
$status_code = 400;
$status_text = 'Bad Request';
$message = implode(
' ',
array_map(
static function ( DOMElement $element ) {
return $element->textContent;
},
iterator_to_array( $error_texts )
)
);
} elseif ( $template_body instanceof DOMElement ) {
$message = $dom->saveHTML( $template_body );
}
}
}
$data = compact( 'status_code', 'status_text' );
if ( $message ) {
$data['message'] = $message;
}
return new WP_REST_Response(
$data,
$status_code,
[
'Access-Control-Allow-Origin' => $this->origin,
]
);
}
/**
* Create error response.
*
* @param string $message Message.
* @param int $code Code.
* @return WP_REST_Response
*/
private function create_error_response( $message, $code = 400 ) {
return new WP_REST_Response(
compact( 'message' ),
$code,
[ 'Access-Control-Allow-Origin' => $this->origin ]
);
}
}
@chvillanuevap
Copy link

I just tested this with the latest version of WordPress AMP, and my submission form is still broken.

@westonruter
Copy link
Author

As per ampproject/amp-wp#4191 (comment), this idea to implement a proxy has been abandoned.

Otherwise, probably your best bet would be to implement your own internal REST API endpoint in WordPress and have it proxy the request to the external server.

@chvillanuevap
Copy link

Does this mean AMP is incompatible with subscription forms, like the ones used by Mailchimp?

@westonruter
Copy link
Author

Yes, unless the external endpoint is able to send back JSON responses and/or the AMP-Redirect-To response header. There is one alternative and that is to utilize an iframe. Here you can see an example of that: https://amp-external-subscription-form-in-iframe.glitch.me/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment