` with no action: view.js
* points it at admin-post.php and adds the handler's fields when it runs. When
* it doesn't (scripting off, the view script blocked or failed to load), the
* browser posted to the page itself, nothing handled it, and the visitor's
* answers were lost.
*
* This fills in at render time what view.js would have: the action URL
* (admin-post.php, routed by its query string), a nonce and a timestamp. It
* also prints the result of the admin-post redirect into the form's message
* box, which only view.js did before. Rendering it server-side leaves save(),
* its deprecations and the PHP serializer mirror untouched, and fixes every
* form already saved.
*
* @package airo-wp
* @since 2.10.0
*/
declare(strict_types=1);
namespace GoDaddy\WordPress\Plugins\AiroWp\Blocks\Common\Forms;
defined( 'ABSPATH' ) || exit;
/**
* Form_No_JS_Submit.
*/
class FormNoJSSubmit {
/**
* Admin-post action, and the nonce action it verifies.
*/
const ACTION = 'airowp_form_submit';
/**
* Where a form posts when it doesn't submit over AJAX.
*
* The admin-post action travels in the URL, not as a hidden `action`
* field: an author's own field may be called `action`, and the two would
* collide in the POST body, losing one value (FormHandler catches these
* requests on admin_init, before admin-post.php routes on `action`).
*
* @return string URL.
*/
public static function post_url() {
return add_query_arg( 'action', self::ACTION, admin_url( 'admin-post.php' ) );
}
/**
* Constructor.
*/
public function __construct() {
add_filter( 'render_block_airo-wp/form-builder', array( $this, 'render' ), 10, 1 );
add_action( 'template_redirect', array( $this, 'prevent_caching_status_page' ) );
}
/**
* Fragment id of a form's server-rendered message.
*
* The admin-post handler appends it to the URL it redirects back to, so a
* visitor without JavaScript lands on the message rather than the top of
* a long page.
*
* @param string $form_id Form ID.
* @return string Element id, or '' when the form ID has no usable characters.
*/
public static function message_anchor( $form_id ) {
$slug = sanitize_html_class( (string) $form_id );
return '' === $slug ? '' : 'airo-wp-form-message-' . $slug;
}
/**
* Make a rendered form submit to admin-post.php without JavaScript.
*
* @param string $block_content Rendered block HTML.
* @return string
*/
public function render( $block_content ) {
if ( ! is_string( $block_content ) || false === strpos( $block_content, 'airo-wp-form' ) ) {
return $block_content;
}
$processor = new \WP_HTML_Tag_Processor( $block_content );
if ( ! $processor->next_tag( array( 'class_name' => 'airo-wp-form-builder' ) ) ) {
return $block_content;
}
// The same attributes view.js reads its messages from.
$form_id = self::attribute( $processor, 'data-form-id' );
$success = self::attribute( $processor, 'data-success-message' );
$error = self::attribute( $processor, 'data-error-message' );
$turnstile = 'true' === self::attribute( $processor, 'data-airo-wp-turnstile' );
if ( ! $processor->next_tag( array( 'tag_name' => 'FORM' ) ) ) {
return $block_content;
}
if ( '' === self::attribute( $processor, 'action' ) ) {
$processor->set_attribute( 'action', self::post_url() );
}
// Never add a field the markup already carries (a second pass of this
// filter, or a field someone else injected): duplicate names post twice.
// Only our own copy counts for `_wpnonce`: an author's field may share
// the name, and is a value to submit, not ours.
$present = array();
while ( $processor->next_tag( array( 'tag_name' => 'INPUT' ) ) ) {
$name = self::attribute( $processor, 'name' );
if ( 0 === strpos( $name, 'dsg_' ) || null !== $processor->get_attribute( 'data-airo-wp-system' ) ) {
$present[ $name ] = true;
}
}
$html = $processor->get_updated_html();
$hidden = '';
foreach ( $this->hidden_fields() as $name => $value ) {
if ( ! isset( $present[ $name ] ) ) {
$hidden .= '';
}
}
$status = $this->redirect_status( $form_id );
if ( '' !== $status ) {
$message = 'success' === $status
? ( '' !== $success ? $success : __( 'Form submitted successfully!', 'airo-wp' ) )
: ( '' !== $error ? $error : __( 'An error occurred. Please try again.', 'airo-wp' ) );
$detail = '';
if ( 'error' === $status ) {
list( $code, $field ) = $this->redirect_reason();
$detail = Form_No_JS_Retry::reason( $code, Form_No_JS_Refill::field_label( $html, $field ) );
$html = Form_No_JS_Refill::refill( $html, Form_No_JS_Retry::values( $form_id ) );
$html = Form_No_JS_Refill::mark_invalid( $html, $field, self::message_anchor( $form_id ) );
}
list( $html, $unplaced ) = $this->print_message( $html, $status, $message, $form_id, $detail );
$hidden .= $unplaced;
}
$close = strripos( $html, '' );
if ( '' !== $hidden && false !== $close ) {
$html = substr_replace( $html, $hidden, $close, 0 );
}
if ( $turnstile ) {
$html = $this->add_turnstile_notice( $html );
}
return $html;
}
/**
* Keep a page showing a form result out of page caches.
*
* The message is printed for this request's query string. A cache that
* ignored the query string would otherwise serve one visitor's "Thank you"
* to everyone. Most page-cache plugins honour DONOTCACHEPAGE; proxies and
* CDNs honour the no-cache headers.
*/
public function prevent_caching_status_page() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only decides cacheability; no data is processed.
if ( ! isset( $_GET['airowp_form_status'] ) ) {
return;
}
// Read (and clear) a failed submission's answers before output starts.
Form_No_JS_Retry::capture();
nocache_headers();
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Shared page-cache convention, not ours to prefix.
define( 'DONOTCACHEPAGE', true );
}
}
/**
* Fields the admin-post handler reads, which view.js otherwise adds.
*
* The timestamp is the render time in milliseconds, the format view.js
* sends, so the "too fast" check applies to a no-JS submission too. A page
* served from cache carries an older time, which only makes the minimum
* easier to meet; there is no maximum age. The nonce goes stale on a page
* cached for more than a day, which the handler tolerates for logged-out
* visitors only (see FormHandler::handle_post_submission()).
*
* @return array Field name => value.
*/
private function hidden_fields() {
return array(
'_wpnonce' => wp_create_nonce( self::ACTION ),
'dsg_timestamp' => time() . '000',
);
}
/**
* The admin-post result for this form, if this request is its redirect.
*
* @param string $form_id Form ID from the rendered markup.
* @return string 'success', 'error', or '' when the request isn't for this form.
*/
private function redirect_status( $form_id ) {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only: picks which fixed message to print.
if ( '' === $form_id || ! isset( $_GET['airowp_form_status'], $_GET['airowp_form_id'] ) || ! is_string( $_GET['airowp_form_status'] ) || ! is_string( $_GET['airowp_form_id'] ) ) {
return '';
}
$status = sanitize_key( wp_unslash( $_GET['airowp_form_status'] ) );
$status_id = sanitize_text_field( wp_unslash( $_GET['airowp_form_id'] ) );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
if ( sanitize_text_field( $form_id ) !== $status_id || ! in_array( $status, array( 'success', 'error' ), true ) ) {
return '';
}
return $status;
}
/**
* Why the submission failed, from the redirect's query string.
*
* @return array{0: string, 1: string} Error code and field name ('' when absent).
*/
private function redirect_reason() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only: picks a fixed sentence and a field to look up in the page's own markup.
$code = isset( $_GET['airowp_form_error'] ) && is_string( $_GET['airowp_form_error'] ) ? sanitize_key( wp_unslash( $_GET['airowp_form_error'] ) ) : '';
$field = isset( $_GET['airowp_form_field'] ) && is_string( $_GET['airowp_form_field'] ) ? sanitize_text_field( wp_unslash( $_GET['airowp_form_field'] ) ) : '';
// phpcs:enable WordPress.Security.NonceVerification.Recommended
return array( $code, $field );
}
/**
* Print the result into the form's message box.
*
* The box save() writes is empty and hidden (`style="display:none"`), so it
* is replaced whole. Forms saved before it existed get one appended to the
* form instead. view.js recognises `data-airo-wp-server-message` and replaces
* or hides it, so a page with scripting never shows the message twice.
*
* @param string $html Rendered form HTML.
* @param string $status 'success' or 'error'.
* @param string $message Message text (unescaped).
* @param string $form_id Form ID.
* @param string $detail Why it failed (unescaped), or ''.
* @return array{0: string, 1: string} The HTML, and a box still to append to the form ('' when placed).
*/
private function print_message( $html, $status, $message, $form_id, $detail = '' ) {
$anchor = self::message_anchor( $form_id );
$box = sprintf(
'
%4$s%5$s
',
esc_attr( $status ),
'' === $anchor ? '' : ' id="' . esc_attr( $anchor ) . '"',
'error' === $status ? 'alert' : 'status',
esc_html( $message ),
'' === $detail ? '' : ' ' . esc_html( $detail ) . '',
// A live region already in the page when it loads isn't announced.
// Focusable, the error is focused by the #anchor the redirect
// lands on, so a screen reader reads it.
'error' === $status ? ' tabindex="-1"' : ''
);
$count = 0;
$html = (string) preg_replace_callback(
'#
]*>\s*
#',
static function () use ( $box ) {
return $box;
},
$html,
1,
$count
);
return array( $html, 0 === $count ? $box : '' );
}
/**
* Tell visitors without JavaScript that a Turnstile form can't be sent.
*
* The challenge is rendered by script, so without it the server rejects
* every submission. Saying so up front beats a "please try again" that
* can never succeed.
*
* @param string $html Rendered form HTML.
* @return string
*/
private function add_turnstile_notice( $html ) {
$container = 'data-airo-wp-turnstile-container="true">';
$at = strpos( $html, $container );
if ( false === $at ) {
return $html;
}
$notice = '';
return substr_replace( $html, $notice, $at + strlen( $container ), 0 );
}
/**
* A string attribute of the current tag ('' when absent or boolean).
*
* @param \WP_HTML_Tag_Processor $processor Processor on the tag.
* @param string $name Attribute name.
* @return string
*/
private static function attribute( \WP_HTML_Tag_Processor $processor, $name ) {
$value = $processor->get_attribute( $name );
return is_string( $value ) ? $value : '';
}
}