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 ]
);
}
}
@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