list = $quote_list; add_action( 'wp_loaded', array( $this, 'maybe_handle' ), 30 ); } /** * Plain-text label of a stored customer field, for "Label: value" lists. Trailing * punctuation is dropped, so a sentence label (consent) does not end up as "quote.:". * * @param array $fields Field definitions. * @param string $key Field key. * @return string */ public static function field_label( $fields, $key ) { $label = isset( $fields[ $key ]['label'] ) ? wp_strip_all_tags( $fields[ $key ]['label'] ) : ucfirst( str_replace( '_', ' ', $key ) ); return rtrim( $label, " .:\t\n" ); } /** * Form fields definition. Pro replaces this with the field editor. * * @return array */ public static function fields() { return apply_filters( 'bbquote_form_fields', array( 'name' => array( 'label' => __( 'Name', 'boring-quote' ), 'type' => 'text', 'required' => true, ), 'email' => array( 'label' => __( 'Email', 'boring-quote' ), 'type' => 'email', 'required' => true, ), 'phone' => array( 'label' => __( 'Phone', 'boring-quote' ), 'type' => 'tel', 'required' => false, ), 'message' => array( 'label' => __( 'Message', 'boring-quote' ), 'type' => 'textarea', 'required' => false, ), 'consent' => array( 'label' => self::consent_label(), 'type' => 'checkbox', 'required' => true, ), ) ); } /** * GDPR consent label from settings, with a link to the privacy policy page when one is set. * * @return string HTML (escaped on output with wp_kses_post). */ public static function consent_label() { $text = trim( (string) Plugin::setting( 'consent_text', '' ) ); if ( '' === $text ) { $text = __( 'I agree to the processing of my personal data in order to prepare a quote.', 'boring-quote' ); } $url = function_exists( 'get_privacy_policy_url' ) ? get_privacy_policy_url() : ''; if ( $url ) { $text .= sprintf( ' %s', esc_url( $url ), esc_html__( 'Privacy policy', 'boring-quote' ) ); } return $text; } /** * Spam checks that need no user interaction: honeypot, minimum fill time, per-IP rate limit. * * @param array $post Unslashed $_POST. * @param int $now Current timestamp. * @param int $attempts Submissions from this IP in the current window (before this one). * @return string '' when OK, otherwise 'honeypot'|'too_fast'|'rate_limited'. */ public static function spam_check( array $post, $now, $attempts ) { if ( ! empty( $post['bbquote_website'] ) ) { return 'honeypot'; } $started = isset( $post['bbquote_ts'] ) ? absint( $post['bbquote_ts'] ) : 0; $min_time = (int) apply_filters( 'bbquote_submit_min_seconds', 3 ); if ( $started && ( $now - $started ) < $min_time ) { return 'too_fast'; } if ( $attempts >= (int) apply_filters( 'bbquote_submit_rate_limit', 5 ) ) { return 'rate_limited'; } return ''; } /** * Transient key for the per-IP counter. The IP is hashed, never stored in clear. * * @return string */ protected static function rate_key() { $ip = class_exists( '\WC_Geolocation' ) ? \WC_Geolocation::get_ip_address() : ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' ); return 'bbquote_rate_' . substr( wp_hash( $ip ), 0, 20 ); } /** * Supported field types. Pro's field editor offers exactly these. * * @return array */ public static function field_types() { return array( 'text', 'email', 'tel', 'number', 'textarea', 'select', 'checkbox' ); } /** * Sanitize a raw submitted value according to the field type. * * @param array $field Field definition. * @param mixed $raw Raw value (unslashed). * @return string */ public static function sanitize_value( $field, $raw ) { $raw = is_scalar( $raw ) ? (string) $raw : ''; switch ( $field['type'] ) { case 'email': return sanitize_email( $raw ); case 'textarea': return sanitize_textarea_field( $raw ); case 'checkbox': return '' !== $raw ? 'yes' : ''; case 'number': return is_numeric( $raw ) ? (string) ( 0 + $raw ) : ''; case 'select': $options = isset( $field['options'] ) ? (array) $field['options'] : array(); $raw = sanitize_text_field( $raw ); return in_array( $raw, $options, true ) ? $raw : ''; default: return sanitize_text_field( $raw ); } } /** * Validate and process the form. */ public function maybe_handle() { if ( empty( $_POST['bbquote_submit_nonce'] ) ) { return; } if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['bbquote_submit_nonce'] ) ), 'bbquote_submit' ) ) { // Typically a cached page with a stale nonce: tell the customer instead of dropping the form silently. wc_add_notice( __( 'Your session has expired. Please try again.', 'boring-quote' ), 'error' ); return; } $rate_key = self::rate_key(); $attempts = (int) get_transient( $rate_key ); $spam = self::spam_check( wp_unslash( $_POST ), time(), $attempts ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only flags are read. if ( 'honeypot' === $spam ) { return; // Silently drop bots. } if ( 'too_fast' === $spam ) { wc_add_notice( __( 'The form was sent too quickly. Please wait a moment and try again.', 'boring-quote' ), 'error' ); return; } if ( 'rate_limited' === $spam ) { wc_add_notice( __( 'Too many requests from your network. Please try again in a few minutes.', 'boring-quote' ), 'error' ); return; } $errors = array(); $customer = array(); foreach ( self::fields() as $key => $field ) { $raw = isset( $_POST[ 'bbquote_' . $key ] ) ? wp_unslash( $_POST[ 'bbquote_' . $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized in sanitize_value(). $value = self::sanitize_value( $field, $raw ); if ( 'email' === $field['type'] && '' !== $value && ! is_email( $value ) ) { $errors[] = __( 'Please enter a valid email address.', 'boring-quote' ); } if ( ! empty( $field['required'] ) && '' === $value ) { if ( 'checkbox' === $field['type'] ) { // Checkbox labels are whole sentences (e.g. GDPR consent), so "