[a-z0-9_-]{1,64})/submit', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'submit' ), // Public form. The callback checks the wp_rest nonce and returns a refreshable 403 // when it is stale. Honeypot, timer and rate limit run in submit(). 'permission_callback' => array( __CLASS__, 'can_submit' ), 'args' => array( 'form_id' => array( 'type' => 'string', 'required' => true ), 'post_id' => array( 'type' => 'integer', 'required' => true, 'minimum' => 1 ), 'answers' => array( 'type' => 'object', 'required' => true ), 'path' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ), ), ) ); register_rest_route( self::NS, '/forms/nonce', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'nonce' ), 'permission_callback' => array( __CLASS__, 'can_refresh_nonce' ), ) ); } /** * Visitors may ask for a fresh REST nonce. The response contains no site data. * * @param WP_REST_Request $request Request. * @return bool */ public static function can_refresh_nonce( WP_REST_Request $request ) { return 'GET' === $request->get_method(); } /** * Public submit is allowed only with a valid REST nonce. * * @param WP_REST_Request $request Request. * @return true|\WP_Error */ public static function can_submit( WP_REST_Request $request ) { return self::rest_nonce_ok( $request ) ? true : self::nonce_error(); } /** * Fresh nonce for cached pages (never cached). */ public static function nonce() { nocache_headers(); $res = new WP_REST_Response( array( 'nonce' => wp_create_nonce( 'wp_rest' ) ) ); $res->header( 'Cache-Control', 'no-store, max-age=0' ); return $res; } /** * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public static function submit( WP_REST_Request $request ) { if ( ! self::rest_nonce_ok( $request ) ) { return self::nonce_error(); } $form_id = sanitize_key( (string) $request['form_id'] ); $post_id = absint( $request['post_id'] ); $body = (array) $request->get_json_params(); if ( empty( $body ) ) { $body = (array) $request->get_body_params(); } $settings = Form_Locator::find( $post_id, $form_id ); if ( null === $settings ) { return new WP_Error( 'reactwoo_atomic_form_not_found', __( 'This form is no longer available.', 'reactwoo-atomic' ), array( 'status' => 404 ) ); } $pro = Forms_Gate::pro_active(); $config = self::config( $settings, $pro ); $spam = $config['spam']; if ( ! empty( $spam['honeypot'] ) && Spam_Guard::is_honeypot_tripped( $body ) ) { return self::fake_success(); } if ( Spam_Guard::is_too_fast( $body, $form_id, $spam['min_seconds'] ) ) { return self::fake_success(); } $ip = Spam_Guard::client_ip(); if ( Spam_Guard::hit_rate_limit( $form_id, $ip, $spam['rate_limit'], $spam['rate_window'] ) ) { return new WP_Error( 'reactwoo_atomic_rate_limited', __( 'Too many attempts — please try again in a few minutes.', 'reactwoo-atomic' ), array( 'status' => 429 ) ); } if ( $pro ) { // Pro anti-spam (Turnstile): true = pass, WP_Error = visible failure, false = silent drop. $check = apply_filters( 'reactwoo/atomic/forms/spam_check', true, $config, $body, $ip ); if ( is_wp_error( $check ) ) { return $check; } if ( true !== $check ) { return self::fake_success(); } } $answers = isset( $body['answers'] ) && is_array( $body['answers'] ) ? $body['answers'] : array(); $steps = $config['steps']; // Server-computed path: Free = linear. Pro (Branching) re-runs the rules on the answers. $path = Multistep_Form_Schema::linear_path( $steps ); $hidden = array(); $option_sets = array(); $extra = array( 'tracking' => array(), 'geo' => array() ); if ( $pro ) { $path = (array) apply_filters( 'reactwoo/atomic/forms/path', $path, $config, $answers ); $hidden = (array) apply_filters( 'reactwoo/atomic/forms/hidden_fields', array(), $config, $answers, $path ); $option_sets = (array) apply_filters( 'reactwoo/atomic/forms/option_sets', array(), $config ); $extra = (array) apply_filters( 'reactwoo/atomic/forms/entry_extra', $extra, $config, $body, $request ); } $result = Multistep_Form_Schema::validate( $steps, $answers, $path, $hidden, $option_sets ); if ( ! empty( $result['errors'] ) ) { return new WP_REST_Response( array( 'ok' => false, 'code' => 'reactwoo_atomic_invalid', 'errors' => $result['errors'], 'steps' => self::error_steps( $steps, $result['errors'] ), ), 422 ); } $tags = array(); if ( $pro ) { // Pro blocklist: same fake success as the honeypot, so senders learn nothing. if ( apply_filters( 'reactwoo/atomic/forms/blocked', false, $config, $result['clean'], $ip ) ) { return self::fake_success(); } $tags = self::clean_tags( apply_filters( 'reactwoo/atomic/forms/lead_tags', array(), $config, $result['clean'] ) ); } $reply_key = $config['reply_to_field']; $email = isset( $result['clean'][ $reply_key ] ) && is_string( $result['clean'][ $reply_key ] ) ? strtolower( $result['clean'][ $reply_key ] ) : ''; $ua = $request->get_header( 'user_agent' ); $entry_id = 0; if ( $config['store_entries'] ) { $entry_id = Submission_Store::insert( array( 'form_id' => $form_id, 'form_name' => $config['form_name'], 'page_id' => $post_id, 'path' => $path, 'answers' => $result['clean'], 'labels' => $result['labels'], 'tracking' => isset( $extra['tracking'] ) ? $extra['tracking'] : array(), 'geo' => isset( $extra['geo'] ) ? $extra['geo'] : array(), 'ip_hash' => Spam_Guard::ip_hash( $ip ), 'ua_hash' => $ua ? substr( hash( 'sha256', (string) $ua ), 0, 40 ) : '', 'email' => $email, 'tags' => $tags, ) ); } Mailer::send( $config, $result, $entry_id, array( 'page_id' => $post_id, 'path' => $path, 'tracking' => isset( $extra['tracking'] ) ? $extra['tracking'] : array(), 'geo' => isset( $extra['geo'] ) ? $extra['geo'] : array(), 'tags' => $tags, ) ); $payload = array( 'answers' => $result['clean'], 'labels' => $result['labels'], 'path' => $path, 'post_id' => $post_id, 'tags' => $tags ) + $extra; do_action( 'reactwoo/atomic/forms/submitted', $entry_id, $payload, $form_id, $config ); $response = array( 'ok' => true, 'entry_id' => $entry_id ); if ( 'redirect' === $config['after_submit'] && '' !== $config['redirect_url'] ) { $response['redirect'] = $config['redirect_url']; if ( $pro && $config['pass_summary'] && $entry_id ) { $response['redirect'] = add_query_arg( 'reactwoo_atomic_s', Summary_Token::sign( $entry_id ), $config['redirect_url'] ); } } if ( $pro ) { $response = (array) apply_filters( 'reactwoo/atomic/forms/response', $response, $config, $result, $entry_id ); } return new WP_REST_Response( $response, 200 ); } /** * Server copy of the form config, normalised; Pro-only props are dropped when the gate fails. * * @param array $s Unwrapped element settings. * @param bool $pro Pro gate. * @return array */ public static function config( array $s, $pro ) { $steps = Multistep_Form_Schema::normalise( isset( $s['steps'] ) && '' !== $s['steps'] ? $s['steps'] : Multistep_Form_Schema::default_steps() ); $spam = isset( $s['spam'] ) ? ( is_array( $s['spam'] ) ? $s['spam'] : json_decode( (string) $s['spam'], true ) ) : array(); $spam = is_array( $spam ) ? $spam : array(); $url = isset( $s['redirect_url'] ) ? esc_url_raw( (string) ( is_array( $s['redirect_url'] ) ? ( isset( $s['redirect_url']['url'] ) ? $s['redirect_url']['url'] : '' ) : $s['redirect_url'] ) ) : ''; $config = array( 'form_id' => isset( $s['form_id'] ) ? (string) $s['form_id'] : '', 'form_name' => isset( $s['form_name'] ) && '' !== $s['form_name'] ? sanitize_text_field( $s['form_name'] ) : __( 'Contact enquiry', 'reactwoo-atomic' ), 'steps' => $steps['steps'], 'admin_email' => isset( $s['admin_email'] ) ? (string) $s['admin_email'] : '', 'email_subject' => isset( $s['email_subject'] ) ? sanitize_text_field( $s['email_subject'] ) : '', 'reply_to_field' => isset( $s['reply_to_field'] ) && '' !== $s['reply_to_field'] ? sanitize_key( $s['reply_to_field'] ) : 'email', 'store_entries' => ! isset( $s['store_entries'] ) || ! empty( $s['store_entries'] ), 'after_submit' => isset( $s['after_submit'] ) && 'redirect' === $s['after_submit'] ? 'redirect' : 'message', 'redirect_url' => wp_validate_redirect( $url, '' ), 'pass_summary' => $pro && ! empty( $s['pass_summary'] ), 'spam' => array( 'honeypot' => ! isset( $spam['honeypot'] ) || ! empty( $spam['honeypot'] ), 'min_seconds' => isset( $spam['min_seconds'] ) ? max( 0, min( 60, (int) $spam['min_seconds'] ) ) : Spam_Guard::DEFAULT_MIN_SECS, 'rate_limit' => isset( $spam['rate_limit'] ) ? max( 0, min( 100, (int) $spam['rate_limit'] ) ) : Spam_Guard::DEFAULT_LIMIT, 'rate_window' => isset( $spam['rate_window'] ) ? max( 60, min( DAY_IN_SECONDS, (int) $spam['rate_window'] ) ) : Spam_Guard::DEFAULT_WINDOW, ), 'raw' => $pro ? $s : array(), ); return $config; } /** * Map field errors to step keys so the client can jump to the first failing step. * * @param array $steps Steps. * @param array $errors Errors keyed by field. * @return array field => step */ private static function error_steps( array $steps, array $errors ) { $out = array(); foreach ( $steps as $step ) { foreach ( $step['fields'] as $f ) { if ( isset( $errors[ $f['key'] ] ) ) { $out[ $f['key'] ] = $step['key']; } } } return $out; } /** * @param mixed $tags Tags from the lead_tags filter. * @return string[] */ public static function clean_tags( $tags ) { $out = array(); foreach ( (array) $tags as $t ) { $t = is_scalar( $t ) ? substr( preg_replace( '/[^a-z0-9_\-:.]/', '', strtolower( trim( (string) $t ) ) ), 0, 64 ) : ''; if ( '' !== $t ) { $out[] = $t; } } return array_slice( array_values( array_unique( $out ) ), 0, 20 ); } private static function fake_success() { return new WP_REST_Response( array( 'ok' => true, 'entry_id' => 0 ), 200 ); } /** * @param WP_REST_Request $request Request. * @return bool */ private static function rest_nonce_ok( WP_REST_Request $request ) { $nonce = (string) $request->get_header( 'X-WP-Nonce' ); if ( '' === $nonce ) { $nonce = (string) $request->get_param( '_wpnonce' ); } return '' !== $nonce && false !== wp_verify_nonce( $nonce, 'wp_rest' ); } /** * @return WP_Error */ private static function nonce_error() { return new WP_Error( 'rest_cookie_invalid_nonce', __( 'Cookie check failed', 'reactwoo-atomic' ), array( 'status' => 403 ) ); } }