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 "