...] /* ============================== Public API ============================== */ /** * Extend the allow-list of safe inline CSS properties for KSES. * * @param array $props Existing list of allowed CSS properties. * @return array Updated, de-duplicated list of properties. */ public function wpbc_bfa_safe_style_css( $props ) { $props[] = 'display'; // allow display $props[] = 'visibility'; // (optional) allow visibility $props[] = 'opacity'; // (optional) return array_values( array_unique( $props ) ); } /** * Render a booking form string by parsing supported shortcodes into HTML. * * @param string $form Raw form markup containing shortcodes. * @return string Form HTML with shortcodes replaced. */ public function render( $form ) { // Allow some inline styles in SHORTCODESS of the booking form parser. add_filter( 'safe_style_css', array( $this, 'wpbc_bfa_safe_style_css' ), 10, 1 ); $form = $this->form_elements( $form, true ); remove_filter( 'safe_style_css', array( $this, 'wpbc_bfa_safe_style_css' ) ); return $form; } /** * Parse all shortcodes in a form string without replacing them. * * @param string $form Raw form markup containing shortcodes. * @return array> Token arrays describing each shortcode found. */ public function parse( $form ) { return $this->form_elements( $form, false ); } /* ============================ Core Dispatcher ============================ */ /** * Core dispatcher: finds shortcode tokens and either replaces them or returns parsed tokens. * * @param string $form Source form string. * @param bool $replace When true, returns rendered HTML; when false, returns parsed tokens. * @return string|array> Rendered form HTML or parsed token arrays. */ public function form_elements( $form, $replace = true ) { // Deprected old code: // $types = 'text[*]?|email[*]?|coupon[*]?|time[*]?|textarea[*]?|select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?|acceptance|captchac|captchar|file[*]?|quiz'; // $regex = '%\[\s*(' . $types . ')(\s+[a-zA-Z][0-9a-zA-Z:._-]*)([-0-9a-zA-Z:#_/|\s]*)?((?:\s*(?:"[^"]*"|\'[^\']*\'))*)?\s*\]%'; // $regex_start_end_time = '%\[\s*(country[*]?|starttime[*]?|endtime[*]?)(\s*[a-zA-Z]*[0-9a-zA-Z:._-]*)([-0-9a-zA-Z:#_/|\s]*)*((?:\s*(?:"[^"]*"|\'[^\']*\'))*)?\s*\]%'; // $submit_regex = '%\[\s*submit(\s[-0-9a-zA-Z:#_/\s]*)?(\s+(?:"[^"]*"|\'[^\']*\'))?\s*\]%'; $types = 'text[*]?|email[*]?|coupon[*]?|time[*]?|textarea[*]?|select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?|acceptance|captchac|captchar|file[*]?|quiz'; $regex = '%\[\s*(' . $types . ')' . '(\s+[a-zA-Z][0-9a-zA-Z:._-]*)' . '((?:\s*(?:"[^"]*"|\'[^\']*\'|[^"\]\s]+))*)' . '\s*\]%u'; $regex_start_end_time = '%\[\s*(country[*]?|starttime[*]?|endtime[*]?)' . '(\s*[a-zA-Z]*[0-9a-zA-Z:._-]*)' . '((?:\s*(?:"[^"]*"|\'[^\']*\'|[^"\]\s]+))*)' . '\s*\]%u'; $submit_regex = '%\[\s*submit' . '((?:\s*(?:"[^"]*"|\'[^\']*\'|[^"\]\s]+))*)' . '\s*\]%u'; if ( $replace ) { $form = preg_replace_callback( $regex, array( $this, 'form_element_replace_callback' ), $form ); $form = preg_replace_callback( $regex_start_end_time, array( $this, 'form_element_replace_callback', ), $form ); $form = preg_replace_callback( $submit_regex, array( $this, 'submit_replace_callback' ), $form ); return $form; } $out = array(); preg_match_all( $regex, $form, $m1, PREG_SET_ORDER ); preg_match_all( $regex_start_end_time, $form, $m2, PREG_SET_ORDER ); foreach ( array_merge( $m1, $m2 ) as $m ) { $out[] = (array) $this->form_element_parse( $m ); } return $out; } /** * preg_replace_callback handler for all non-submit shortcodes. * * @param array $matches Regex matches for a single shortcode. * @return string Rendered HTML for the matched shortcode. */ public function form_element_replace_callback( $matches ) { // Provide defaults for extract() $type = $name = ''; $options = $raw_values = $values = array(); $placeholder_text = null; $named = array(); extract( (array) $this->form_element_parse( $matches ) ); // $type, $name, $options, $values, $raw_values, $placeholder_text. Now includes $named. // Normalize name for country. if ( $type === 'country' || $type === 'country*' ) { if ( $name === '' ) { $name = $type; } } // Suffix booking type for runtime uniqueness $name_with_suffix = $name . $this->current_resource_id; // Apply edit-booking defaults (select/radio/checkbox/country) list( $values, $options ) = $this->apply_edit_defaults( $type, $name_with_suffix, $values, $options ); // Build common attributes: autocomplete, id, placeholder, class flags. $atts = $this->build_common_atts( $type, $name, $name_with_suffix, $options, $placeholder_text, $named ); $validation_error = $this->build_validation_error( $name_with_suffix ); // Restore posted value on validation error (for text-ish types) $restored_value = $this->restore_posted_value( $type, $name_with_suffix, isset( $values[0] ) ? $values[0] : '' ); // Dispatch per field type switch ( $type ) { case 'text': case 'text*': case 'email': case 'email*': case 'coupon': case 'coupon*': case 'time': case 'time*': case 'starttime': case 'starttime*': case 'endtime': case 'endtime*': case 'captchar': return $this->render_text_like( $type, $name_with_suffix, $atts, $options, $restored_value, $validation_error ); case 'textarea': case 'textarea*': return $this->render_textarea( $name_with_suffix, $atts, $options, $restored_value, $validation_error ); case 'country': case 'country*': return $this->render_country( $name_with_suffix, $atts, $options, $values, $validation_error ); case 'select': case 'select*': case 'selectbox': case 'selectbox*': return $this->render_select( $type, $name_with_suffix, $atts, $options, $values, $validation_error ); case 'checkbox': case 'checkbox*': case 'radio': case 'radio*': return $this->render_checkable_group( $type, $name_with_suffix, $atts, $options, $values, $validation_error ); case 'quiz': return $this->render_quiz( $name_with_suffix, $atts, $options, $raw_values, $values, $validation_error ); case 'acceptance': return $this->render_acceptance( $name_with_suffix, $atts, $options ); case 'captchac': return $this->render_captcha( $name_with_suffix, $atts, $options ); case 'file': case 'file*': return $this->render_file( $name_with_suffix, $atts, $validation_error ); } return ''; } /** * preg_replace_callback handler for the [submit ... "Label"] shortcode. * Supports legacy options (id:, class:) and named attributes (id="", class="", aria-*, data-*, style, ...). * * @param array $matches Regex matches for the submit shortcode. * @return string Rendered HTML. */ public function submit_replace_callback( $matches ) { $atts = ''; $options = array(); $value = ''; // Parse named attributes from the whole shortcode (id="...", class="...", title="...", aria-*, data-*, style, etc.) list( $named_raw, $consumed ) = $this->parse_named_attributes_from_shortcode( $matches[0] ); // Legacy-style options (id:..., class:..., etc.) from the unquoted leftovers of $matches[1]. if ( isset( $matches[1] ) && trim( $matches[1] ) !== '' ) { $opts_no_quotes = preg_replace( '/"[^"]*"|\'[^\']*\'/u', ' ', trim( $matches[1] ) ); $options = preg_split( '/\s+/u', trim( $opts_no_quotes ) ); $options = array_values( array_filter( (array) $options, 'strlen' ) ); } // Legacy id:... has priority over named id="..." $id_legacy = $this->shift_match( '%^id:([-0-9a-zA-Z_]+)$%', $options ); if ( $id_legacy ) { $atts .= ' id="' . $id_legacy . '"'; } elseif ( isset( $named_raw['id'] ) && $named_raw['id'] !== '' ) { $atts .= ' id="' . esc_attr( $named_raw['id'] ) . '"'; } // Legacy class:... + named class="..." $class_legacy = $this->collect_classes( $options ); $class_named = isset( $named_raw['class'] ) ? trim( $named_raw['class'] ) : ''; $btn_classes = trim( 'wpbc_button wpbc_button_primary ' . $class_legacy . ' ' . $class_named ); $atts .= ' class="' . esc_attr( $btn_classes ) . '"'; // Add safe named attributes (title, aria-*, data-*, style if allowed, etc.) foreach ( $named_raw as $k => $v ) { $kk = $this->sanitize_attr_name( $k, 'submit', 'submit' ); if ( $kk === '' ) { continue; } if ( in_array( $kk, array( 'id', 'class' ), true ) ) { continue; } if ( $v === '' ) { continue; } $atts .= ' ' . $kk . '="' . esc_attr( $v ) . '"'; } // Label: take the *last* quoted string not consumed by named-attr values preg_match_all( '/"[^"]*"|\'[^\']*\'/u', $matches[0], $qm ); $quoted = $this->strip_quote_deep( $qm[0] ); // unquoted strings if ( is_array( $quoted ) ) { foreach ( (array) $consumed as $q ) { $idx = array_search( $q, $quoted, true ); if ( $idx !== false ) { unset( $quoted[ $idx ] ); } } $quoted = array_values( $quoted ); if ( ! empty( $quoted ) ) { $value = $quoted[ count( $quoted ) - 1 ]; } } if ( $value === '' ) { $value = __( 'Send', 'booking' ); } $html = ''; // ------------------------------------------------------------------------------------------------------------- // Cancel / Edit Buttons. // ------------------------------------------------------------------------------------------------------------- if ( isset( $_GET['booking_hash'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing $get_booking_hash = ( ( isset( $_GET['booking_hash'] ) ) ? sanitize_text_field( wp_unslash( $_GET['booking_hash'] ) ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */ /* FixIn: sanitize_unslash */ $my_booking_id_type = wpbc_hash__get_booking_id__resource_id( $get_booking_hash ); if ( $my_booking_id_type !== false ) { $my_edited_bk_id = $my_booking_id_type[0]; $admin_uri = ltrim( str_replace( get_site_url( null, '', 'admin' ), '', admin_url( 'admin.php?' ) ), '/' ); $server_request_uri = ( ( isset( $_SERVER['REQUEST_URI'] ) ) ? sanitize_text_field( $_SERVER['REQUEST_URI'] ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash */ /* FixIn: sanitize_unslash */ $server_http_referer_uri = ( ( isset( $_SERVER['HTTP_REFERER'] ) ) ? sanitize_text_field( $_SERVER['HTTP_REFERER'] ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.MissingUnslash */ /* FixIn: sanitize_unslash */ if ( ( strpos( $server_request_uri, $admin_uri ) !== false ) && ( isset( $server_http_referer_uri ) ) ) { $html .= ''; } $value = __( 'Change your Booking', 'booking' ); if ( isset( $_GET['booking_cancel'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing $value = __( 'Cancel Booking', 'booking' ); $wpbc_nonce = wp_nonce_field( 'DELETE_BY_VISITOR', ( "wpbc_nonce_delete" . $this->current_resource_id ), true, false ); $get_booking_hash = ( ( isset( $_GET['booking_hash'] ) ) ? sanitize_text_field( wp_unslash( $_GET['booking_hash'] ) ) : '' ); /* phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing */ /* FixIn: sanitize_unslash */ $html .= $wpbc_nonce . ''; // FixIn: 8.4.2.5. $html .= ''; // FixIn: 10.1.3.7. return $html; } else { // FixIn: 8.4.2.9. if ( wpbc_is_new_booking_page() ) { // FixIn: 8.4.5.9. $html .= ''; } $html .= ''; } } } // ------------------------------------------------------------------------------------------------------------- // Submit Button. // ------------------------------------------------------------------------------------------------------------- $html .= ''; return $html; } /* ============================ Rendering methods ============================ */ /** * Render text-like inputs (text, email, coupon, time, starttime, endtime, captchar). * * @param string $type Shortcode type (e.g., 'text', 'email*', 'starttime'). * @param string $name Field name (already suffixed with booking type). * @param string $atts Prebuilt attribute string (leading space included). * @param array $options Legacy option tokens. * @param string $value Initial value. * @param string $validation_error Validation error HTML (or empty). * @return string Wrapped control HTML. */ private function render_text_like( $type, $name, $atts, $options, $value, $validation_error ) { // Normalize name for start/end time fields if ( $type === 'starttime' || $type === 'starttime*' ) { $name = 'starttime' . $this->current_resource_id; } if ( $type === 'endtime' || $type === 'endtime*' ) { $name = 'endtime' . $this->current_resource_id; } // size/maxlength (default size=40) — respect named attrs if already present list( $size, $maxlength ) = $this->parse_size_maxlength( $options, 40 ); $this->append_att_once( $atts, 'size', $size ); if ( $maxlength > 0 ) { $this->append_att_once( $atts, 'maxlength', $maxlength ); } // coupon onchange handler $additional_js = ( $type === 'coupon' || $type === 'coupon*' ) ? ' onchange="javascript:if(typeof( wpbc_show_cost_hints_after_few_seconds )==\'function\'){wpbc_show_cost_hints_after_few_seconds(' . $this->current_resource_id . ');}" ' : ''; // Input type $field_type = ( $type === 'email' || $type === 'email*' ) ? 'type="email"' : 'type="text"'; $html = ''; return '' . $html . $validation_error . ''; } /** * Render a '; return '' . $html . $validation_error . ''; } /** * Render a country ' . $html . ''; return '' . $html . $validation_error . ''; } /** * Render a generic ' . $html . ''; return '' . $html . $validation_error . ''; } /** * Render checkbox/radio groups with support for label options and defaults. * * @param string $type 'checkbox'|'checkbox*'|'radio'|'radio*'. * @param string $name Field name (with booking type suffix). * @param string $atts Attribute string (may include id for label for=... pairing). * @param array $options Legacy option tokens (use_label_element, label_first, etc). * @param array $values Items possibly in 'Label@@value' format. * @param string $validation_error Validation error HTML. * @return string Wrapped control HTML. */ private function render_checkable_group( $type, $name, $atts, $options, $values, $validation_error ) { // Never reuse the same id="" on multiple inputs. $input_atts = preg_replace( '/\s+id="[^"]*"/i', '', (string) $atts ); $multiple = ( preg_match( '/^checkbox[*]?$/', $type ) && ! preg_grep( '%^exclusive$%', $options ) ); $onclick = ( preg_match( '/^checkbox[*]?$/', $type ) && ! $multiple ) ? ' onclick="wpbc_in_form__make_exclusive_checkbox(this);"' : ''; $default_on = (bool) preg_grep( '%^default:on$%', $options ) ? ' checked="checked"' : ''; $input_type = rtrim( $type, '*' ); $html = ''; $id_attr_for_group = ''; foreach ( (array) $values as $idx => $raw ) { $checked = ''; $label_text = $raw; $value = $raw; if ( strpos( $raw, '@@' ) !== false ) { list( $label_text, $value ) = explode( '@@', $raw, 2 ); } // defaults: default:foo, default:bar,... foreach ( $this->collect_defaults_scalar( $options ) as $dv ) { if ( trim( $dv ) === trim( $value ) && $value !== '' ) { $checked = ' checked="checked"'; } } // label options $is_use_label = preg_grep( '%^use[_-]?label[_-]?element$%', $options ) ? 'label' : 'span'; $is_use_label_first = (bool) preg_grep( '%^label[_-]?first$%', $options ); $is_label_wrap = (bool) preg_grep( '%^label[_-]?wrap$%', $options ); $id_attr_for_checkbox = ''; $label_for_parameter = ''; if ( $is_use_label === 'label' ) { if ( preg_match( '%id="([-0-9a-zA-Z_]+)"%', $atts, $id_matches ) ) { $atts = str_replace( $id_matches[0], '', $atts ); $id_attr_for_group = ' id="' . $id_matches[1] . '" '; $uniq = $id_matches[1] . time() . $idx . wp_rand( 1000, 10000 ); } else { $uniq = 'checkboxid' . time() . $idx . wp_rand( 1000, 10000 ); } $label_for_parameter = ' for="' . $uniq . '" '; $id_attr_for_checkbox = ' id="' . $uniq . '" '; } $item = ''; if ( $is_label_wrap ) { $item = $is_use_label_first ? '<' . $is_use_label . $label_for_parameter . ' class="wpdev-list-item-label">' . esc_html( $label_text ) . $item . '' : '<' . $is_use_label . $label_for_parameter . ' class="wpdev-list-item-label">' . $item . esc_html( $label_text ) . ''; } else { $item_label = '<' . $is_use_label . $label_for_parameter . ' class="wpdev-list-item-label">' . esc_html( $label_text ) . ''; $item = $is_use_label_first ? ( $item_label . $item ) : ( $item . $item_label ); } $html .= '' . $item . ''; } $html = '' . $html . ''; return '' . $html . $validation_error . ''; } /** * Render a simple anti-bot quiz (question + text input + hidden hashed answer). * * @param string $name Field name. * @param string $atts Attribute string. * @param array $options Legacy option tokens (e.g., 20/80). * @param array $raw_values Raw quoted values including pipes ('Q|A'). * @param array $values Extracted question labels. * @param string $validation_error Validation error HTML. * @return string Wrapped control HTML. */ private function render_quiz( $name, $atts, $options, $raw_values, $values, $validation_error ) { $raw_values = (array) $raw_values; if ( count( $raw_values ) === 0 && count( $values ) === 0 ) { $raw_values[] = '1+1=?|2'; $values[] = '1+1=?'; } $pipes = $this->get_pipes( $raw_values ); $label = ( count( $values ) === 0 ) ? '' : ( count( $values ) === 1 ? $values[0] : $values[ array_rand( $values ) ] ); $answer = $this->canonicalize( $this->pipe( $pipes, $label ) ); list( $size, $maxlength ) = $this->parse_size_maxlength( $options, 40 ); $atts .= ' size="' . (int) $size . '"'; if ( $maxlength > 0 ) { $atts .= ' maxlength="' . (int) $maxlength . '"'; } $html = '' . esc_html( $label ) . ' '; $html .= ''; $html .= ''; return '' . $html . $validation_error . ''; } /** * Render an acceptance checkbox that toggles submit availability. * * @param string $name Field name. * @param string $atts Attribute string. * @param array $options Legacy option tokens (default:on, invert). * @return string HTML. */ private function render_acceptance( $name, $atts, $options ) { $default = (bool) preg_grep( '%^default:on$%', $options ); $onclick = ' onclick="wpdevToggleSubmit(this.form);"'; $checked = $default ? ' checked="checked"' : ''; return ''; } /** * Render CAPTCHA image markup (Really Simple CAPTCHA integration). * * @param string $name Field name. * @param string $atts Attribute string (width/height may be added). * @param array $options Legacy option tokens mapped to generator options. * @return string Hidden challenge + HTML, or message if plugin not active. */ private function render_captcha( $name, $atts, $options ) { if ( ! class_exists( 'ReallySimpleCaptcha' ) ) { return '' . esc_html__( 'To use CAPTCHA, you need Really Simple CAPTCHA plugin installed.', 'booking' ) . ''; } $op = array( 'img_size' => array( 72, 24 ), 'base' => array( 6, 18 ), 'font_size' => 14, 'font_char_width' => 15, ); $op = array_merge( $op, $this->captchac_options( $options ) ); if ( ! $filename = $this->generate_captcha( $op ) ) { return ''; } if ( is_array( $op['img_size'] ) ) { $atts .= ' width="' . (int) $op['img_size'][0] . '" height="' . (int) $op['img_size'][1] . '"'; } $captcha_url = trailingslashit( $this->captcha_tmp_url() ) . $filename; $html = '' . esc_attr__( 'To show CAPTCHA, please deactivate cache plugin or exclude this page from caching or disable CAPTCHA at WP Booking Calendar - Settings General page in Form Options section.', 'booking' ) . ''; $ref = substr( $filename, 0, strrpos( $filename, '.' ) ); return '' . $html; } /** * Render a file upload input. * * @param string $name Field name. * @param string $atts Attribute string. * @param string $validation_error Validation error HTML. * @return string Wrapped control HTML. */ private function render_file( $name, $atts, $validation_error ) { $html = ''; return '' . $html . $validation_error . ''; } /* =============================== Helpers =============================== */ /** * Restore a previously submitted value after validation errors. * * @param string $type Shortcode type. * @param string $name Field name (with booking type suffix). * @param string $fallback Fallback value when not restoring. * @return string Restored or fallback value. */ private function restore_posted_value( $type, $name, $fallback ) { return $fallback; } /** * Produce inline validation error HTML for a named field, if present. * * @param string $name Field name (with booking type suffix). * @return string Error span HTML or empty string. */ private function build_validation_error( $name ) { return ''; } /** * Build common HTML attributes for a field from legacy and named attributes. * * @param string $type Shortcode type. * @param string $name_without_suffix Original field name (no booking-type suffix). * @param string $name_with_suffix Field name with booking-type suffix. * @param array &$options Legacy options (consumed as needed). * @param string|null $placeholder_text Named/legacy placeholder text, if any. * @param array $named Sanitized named attributes (id, class, aria-*, etc). * @return string Attribute string beginning with a leading space. */ private function build_common_atts( $type, $name_without_suffix, $name_with_suffix, &$options, $placeholder_text, $named = array() ) { $atts = ''; // Base autocomplete using raw field name (BC). $atts .= ' autocomplete="' . esc_attr( $name_without_suffix ) . '"'; // Legacy id:foo (wins over named id="..."). if ( $id = $this->shift_match( '%^id:([-0-9a-zA-Z_]+)$%', $options ) ) { $atts .= ' id="' . $id . $this->current_resource_id . '"'; } // Legacy placeholder:Enter_name (kept for BC if no named placeholder=...). if ( empty( $named['placeholder'] ) ) { if ( ! empty( $placeholder_text ) ) { // placeholder (quoted first). $atts .= ' placeholder="' . esc_attr( $placeholder_text ) . '"'; } else { if ( $legacy = $this->shift_match( '%^placeholder:([-0-9a-zA-Z_//]+)$%', $options ) ) { $atts .= ' placeholder="' . esc_attr( str_replace( '_', ' ', $legacy ) ) . '"'; } } } // Legacy classes first. $class_att = $this->collect_classes( $options ); // Validation/role CSS (unchanged). if ( preg_match( '/^email[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-email'; if ( preg_match( '/^coupon[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-coupon'; if ( preg_match( '/^time[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-time'; if ( preg_match( '/^starttime[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-time'; if ( preg_match( '/^endtime[*]?$/', $type ) ) $class_att .= ' wpdev-validates-as-time'; if ( preg_match( '/[*]$/', $type ) ) $class_att .= ' wpdev-validates-as-required'; if ( preg_match( '/^checkbox[*]?$/', $type ) ) $class_att .= ' wpdev-checkbox'; if ( preg_match( '/^radio[*]?$/', $type ) ) $class_att .= ' wpdev-radio'; if ( preg_match( '/^captchac$/', $type ) ) $class_att .= ' wpdev-captcha-' . $name_with_suffix; if ( $type === 'acceptance' ) { $class_att .= ' wpdev-acceptance'; if ( preg_grep( '%^invert$%', $options ) ) { $class_att .= ' wpdev-invert'; } } // Merge named class="...". if ( isset( $named['class'] ) && $named['class'] !== '' ) { $class_att .= ' ' . trim( $named['class'] ); } if ( $class_att ) { $atts .= ' class="' . esc_attr( trim( $class_att ) ) . '"'; } // Add the rest of named attributes safely (id/placeholder/class already handled). $atts .= $this->build_named_atts_string( $named, $type, $name_without_suffix, $name_with_suffix, $atts, $options ); return $atts; } /** * Parse a "size/maxlength" token (e.g., "30/100") from legacy options. * * @param array $options Legacy option tokens. * @param int $default_size Default input size if none provided. * @return array{0:int,1:int} [size, maxlength] (maxlength may be 0 if not set). */ private function parse_size_maxlength( $options, $default_size = 40 ) { $size = (int) $default_size; $maxlength = 0; $sm = preg_grep( '%^[0-9]*[/x][0-9]*$%', $options ); if ( $raw = array_shift( $sm ) ) { if ( preg_match( '%^([0-9]*)[/x]([0-9]*)$%', $raw, $mm ) ) { if ( ! empty( $mm[1] ) ) { $size = (int) $mm[1]; } if ( ! empty( $mm[2] ) ) { $maxlength = (int) $mm[2]; } } } return array( $size, $maxlength ); } /** * Collect default values from tokens like "default:foo" (scalar values). * * @param array $options Legacy option tokens. * @return string[] Array of default values. */ private function collect_defaults_scalar( $options ) { $defs = array(); foreach ( preg_grep( '/^default:/', (array) $options ) as $dv ) { // Capture everything after "default:" as-is (trim trailing/leading whitespace). if ( preg_match( '/^default:(.+)$/u', $dv, $m ) ) { $val = trim( (string) $m[1] ); // Historic BC: decode percent entity if present. $val = str_replace( '%', '%', $val ); $defs[] = $val; } } return $defs; } /** * Collect default country codes from a token like "default:US_FR". * * @param array $options Legacy option tokens. * @return string[] Array of ISO-like country codes. */ private function collect_defaults_country( $options ) { // returns array of country codes from default:US_FR (or similar) $def = array_values( preg_grep( '/^default:/', (array) $options ) ); if ( isset( $def[0] ) && preg_match( '/^default:([0-9a-zA-Z_:\s-]+)$/', $def[0], $m ) ) { return explode( '_', $m[1] ); } return array(); } /** * Collect legacy classes from tokens like "class:my_class". * * @param array $options Legacy option tokens. * @return string Space-prefixed class list string (may be empty). */ private function collect_classes( $options ) { $acc = ''; foreach ( preg_grep( '%^class:[-0-9a-zA-Z_]+$%', (array) $options ) as $class ) { if ( preg_match( '%^class:([-0-9a-zA-Z_]+)$%', $class, $m ) ) { $acc .= ' ' . $m[1]; } } return $acc; } /** * Find and remove the first option that matches a regex and return its first capturing group. * * @param string $pattern PCRE pattern with exactly one capturing group. * @param array $options Options array, passed by reference and mutated. * @return string Captured value or empty string if not found. */ private function shift_match( $pattern, &$options ) { foreach ( (array) $options as $i => $opt ) { if ( preg_match( $pattern, $opt, $m ) ) { unset( $options[ $i ] ); return $m[1]; } } return ''; } /** * Find and remove the first option that matches a regex; return the raw matched token. * * @param string $pattern PCRE pattern. * @param array $options Options array, passed by reference and mutated. * @return string Matched token or empty string if not found. */ private function shift_match_raw( $pattern, &$options ) { foreach ( (array) $options as $i => $opt ) { if ( preg_match( $pattern, $opt ) ) { unset( $options[ $i ] ); return $opt; } } return ''; } /** * When editing an existing booking, convert previously saved values into current defaults. * * @param string $type Shortcode type. * @param string $name_with_suffix Field name with booking-type suffix. * @param array $values Current values array (may be modified). * @param array $options Current legacy options (may be modified). * @return array{0:array,1:array} Updated [$values, $options]. */ private function apply_edit_defaults( $type, $name_with_suffix, $values, $options ) { $my_edited_bk_id = false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing if ( isset( $_GET['booking_hash'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing $get_booking_hash = ( isset( $_GET['booking_hash'] ) ? sanitize_text_field( wp_unslash( $_GET['booking_hash'] ) ) : '' ); $pair = function_exists( 'wpbc_hash__get_booking_id__resource_id' ) ? wpbc_hash__get_booking_id__resource_id( $get_booking_hash ) : false; if ( $pair !== false ) { $my_edited_bk_id = $pair[0]; } } if ( $my_edited_bk_id === false ) { return array( $values, $options ); } // Select/radio/checkbox/country: convert previous saved selection into default: if ( preg_match( '/^(?:select|selectbox|country|checkbox|radio)[*]?$/', $type ) ) { if ( isset( $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value'] ) ) { $options = (array) $options; // Remove any existing default: foreach ( $options as $k => $v ) { if ( strpos( $v, 'default:' ) === 0 ) { unset( $options[ $k ] ); } } // Preserve spaces and just trim surrounding whitespace on split parts. $vals = array_map( 'trim', explode( ',', $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value'] ) ); foreach ( $vals as $v ) { $options[] = 'default:' . $v; } } } else { $values[0] = ''; $map = array( 'starttime', 'starttime*', 'endtime', 'endtime*' ); if ( in_array( $type, $map, true ) ) { $key = $type . $this->current_resource_id; if ( isset( $this->current_edit_booking['parsed_form'][ $key ]['value'] ) ) { $values[0] = $this->current_edit_booking['parsed_form'][ $key ]['value']; } } elseif ( $type === 'country' || $type === 'country*' ) { $key = $type . $this->current_resource_id; if ( isset( $this->current_edit_booking['parsed_form'][ $key ]['value'] ) ) { $options[0] = $this->current_edit_booking['parsed_form'][ $key ]['value']; } } else { if ( isset( $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value'] ) ) { $values[0] = $this->current_edit_booking['parsed_form'][ $name_with_suffix ]['value']; } } } return array( $values, $options ); } /* =============================== Parser =============================== */ /** * Parse a matched shortcode token into a structured array (type, name, options, values, etc.). * * @param array $element Regex match array for a shortcode. * @return array{ * type:string, * name:string, * options:array, * values:array, * raw_values:array, * placeholder_text:?string, * named:array * } */ public function form_element_parse( $element ) { $type = trim( $element[1] ); $name = trim( $element[2] ); $opt_str = isset( $element[3] ) ? trim( $element[3] ) : ''; // 1) Build $options from the combined “rest” group, but ignore quoted substrings. $options = array(); if ( $opt_str !== '' ) { $opts_no_quotes = preg_replace( '/"[^"]*"|\'[^\']*\'/u', ' ', $opt_str ); $options = preg_split( '/\s+/u', trim( $opts_no_quotes ) ); $options = array_values( array_filter( $options, 'strlen' ) ); } // 2) Quoted chunks: scan the FULL shortcode text (so order doesn’t matter). preg_match_all( '/"[^"]*"|\'[^\']*\'/u', $element[0], $m ); $raw_values = $this->strip_quote_deep( $m[0] ); // 3) As you already do: list( $named_raw, $consumed ) = $this->parse_named_attributes_from_shortcode( $element[0] ); // Remove named-attr quoted values from $raw_values (unchanged logic). if ( is_array( $raw_values ) && is_array( $consumed ) ) { foreach ( $consumed as $q ) { $idx = array_search( $q, $raw_values, true ); if ( $idx !== false ) { unset( $raw_values[ $idx ] ); } } $raw_values = array_values( $raw_values ); } /* ------------------------------------------------------------------------------------------------- * FixIn: 11.0.3 — Unify 'default' handling: * Treat named attribute default="VALUE" the same as legacy default:VALUE, * and preserve spaces (do NOT replace with underscores). This ensures both * syntaxes select the same option on first render. * ------------------------------------------------------------------------------------------------- */ if ( isset( $named_raw['default'] ) && $named_raw['default'] !== '' ) { $options[] = 'default:' . $named_raw['default']; // De-duplicate if the author happened to include both forms. $options = array_values( array_unique( (array) $options ) ); } // Placeholder precedence: named placeholder=... first, then legacy placeholder:"..."/placeholder:token. $placeholder_text = null; if ( isset( $named_raw['placeholder'] ) && $named_raw['placeholder'] !== '' ) { $placeholder_text = $named_raw['placeholder']; } else { if ( preg_match( '/\bplaceholder\s*:\s*(?:"([^"]*)"|\'([^\']*)\')/u', $element[0], $pm ) ) { $placeholder_text = ( $pm[1] !== '' ) ? $pm[1] : ( isset( $pm[2] ) ? $pm[2] : null ); if ( $placeholder_text !== null && is_array( $raw_values ) ) { $idx = array_search( $placeholder_text, $raw_values, true ); if ( $idx !== false ) { unset( $raw_values[ $idx ] ); $raw_values = array_values( $raw_values ); } } } } // For choice-like fields, keep your existing pipe logic if ( preg_match( '/^(select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?)$/', $type ) || 'quiz' === $type ) { $pipes = $this->get_pipes( $raw_values ); $values = $this->get_pipe_ins( $pipes ); } else { $values =& $raw_values; } // Sanitize named attribute keys (allow aria-*, data-*, block style) $named = array(); foreach ( (array) $named_raw as $k => $v ) { $kk = $this->sanitize_attr_name( $k, $type, $name ); if ( $kk !== '' ) { $named[ $kk ] = $v; } } return compact( 'type', 'name', 'options', 'values', 'raw_values', 'placeholder_text', 'named' ); } /* ======================= Utility / existing helpers ======================= */ /** * Remove surrounding single/double quotes from a string. * * @param string $text Possibly quoted text. * @return string Unquoted text. */ public function strip_quote( $text ) { $text = trim( $text ); if ( preg_match( '/^"(.*)"$/s', $text, $m ) ) { $text = $m[1]; } elseif ( preg_match( "/^'(.*)'$/s", $text, $m ) ) { $text = $m[1]; } return $text; } /** * Apply strip_quote() to a string or every element of an array. * * @param string|array $arr String or array of strings. * @return string|array Unquoted string or array of unquoted strings. */ public function strip_quote_deep( $arr ) { if ( is_string( $arr ) ) { return $this->strip_quote( $arr ); } if ( is_array( $arr ) ) { $out = array(); foreach ( $arr as $k => $text ) { $out[ $k ] = $this->strip_quote( $text ); } return $out; } return array(); } /** * Map a value (or array of values) through "pipes" pairs [in, out]; returns mapped value(s). * * @param array $pipes Array of [label,binding] pairs. * @param string|array $value Value(s) to map. * @return string|array Mapped value(s). */ public function pipe( $pipes, $value ) { if ( is_array( $value ) ) { $res = array(); foreach ( $value as $k => $v ) { $res[ $k ] = $this->pipe( $pipes, $v ); } return $res; } foreach ( $pipes as $p ) { if ( $p[0] == $value ) { return $p[1]; } } return $value; } /** * Return unique "in" values from pipes. * * @param array $pipes Pipes array. * @return string[] Unique list of "in" values. */ public function get_pipe_ins( $pipes ) { $ins = array(); foreach ( $pipes as $pipe ) { $in = $pipe[0]; if ( ! in_array( $in, $ins, true ) ) { $ins[] = $in; } } return $ins; } /** * Convert raw values like "Label|Value" into [Label, Value] pipe pairs. * * @param array $values Raw values (may include '|'). * @return array Pipe pairs [label,binding]. */ public function get_pipes( $values ) { $pipes = array(); foreach ( (array) $values as $value ) { $pos = strpos( $value, '|' ); if ( false === $pos ) { $before = $after = $value; } else { $before = substr( $value, 0, $pos ); $after = substr( $value, $pos + 1 ); } $pipes[] = array( $before, $after ); } return $pipes; } /* ---- Stubs you likely have elsewhere; keep signatures identical ---- */ /** * Canonicalize a string for comparisons/hashing (override upstream if needed). * * @param string $str Input string. * @return string Canonical form (default: unchanged). */ protected function canonicalize( $str ) { return $str; } /** * Map legacy captcha options into generator options (override upstream if needed). * * @param array $options Legacy option tokens. * @return array Captcha generator options. */ protected function captchac_options( $options ) { return array(); } /** * Generate a CAPTCHA image file and return its filename (override upstream if needed). * * @param array $op Options for image generation. * @return string|false Filename (without path) on success, false on failure. */ protected function generate_captcha( $op ) { return false; } /** * Get temporary URL for CAPTCHA images (override upstream if needed). * * @return string Base URL used to serve generated CAPTCHA files. */ protected function captcha_tmp_url() { return ''; } // ================================================================================================================= // FixIn: 11.0.2. 2025-08-22 11:54 // ================================================================================================================= /* == New helpers for named attributes ====================== */ /** * Parse named attributes (id, class, placeholder, aria-*, data-*, style, etc.) from a shortcode slice. * Also returns the set of quoted values that were consumed so they can be excluded from field values. * * @param string $shortcode_text Raw shortcode substring including brackets. * @return array{0:array,1:array} [named attributes, consumed quoted values]. */ private function parse_named_attributes_from_shortcode( $shortcode_text ) { $named = array(); $consumed = array(); // key[:=]"double" | 'single' | unquoted $attr_pattern = '/\b([a-zA-Z][\w:-]*)\s*[:=]\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s\]"]+))/u'; if ( preg_match_all( $attr_pattern, $shortcode_text, $mm, PREG_SET_ORDER ) ) { foreach ( $mm as $m ) { $key = strtolower( $m[1] ); $val = ''; if ( isset( $m[2] ) && $m[2] !== '' ) { $val = $m[2]; $consumed[] = $m[2]; // so we can remove this quoted piece from values } elseif ( isset( $m[3] ) && $m[3] !== '' ) { $val = $m[3]; $consumed[] = $m[3]; } elseif ( isset( $m[4] ) ) { $val = $m[4]; } $named[ $key ] = $val; } } // Normalize boolean-ish flags: multiple="", multiple="multiple", multiple=1 → multiple: true foreach ( array( 'multiple' ) as $bool_key ) { if ( array_key_exists( $bool_key, $named ) && $named[ $bool_key ] === '' ) { $named[ $bool_key ] = '1'; } } return array( $named, $consumed ); } /** * Validate/allow attribute names; permits aria-* and data-* and optionally 'style' via a filter. * * @param string $name Raw attribute name. * @param string $type Shortcode type (used for style gating). * @param string $field_name Original field name (used for style gating). * @return string Sanitized attribute name, or '' if disallowed. */ private function sanitize_attr_name( $name, $type = '', $field_name = '' ) { $name = strtolower( trim( $name ) ); if ( strpos( $name, 'on' ) === 0 ) { return ''; } if ( $name === 'style' ) { // Only allow if opt-in filter returns true for this field/type. return $this->allow_inline_style( $type, $field_name ) ? 'style' : ''; } // basic validity, allow aria-* and data-* if ( preg_match( '/^(?:[a-z][\w:-]*|aria-[\w:-]+|data-[\w:-]+)$/', $name ) ) { return $name; } return ''; } /** * Build a string of allowed named attributes for an element, avoiding duplicates and honoring filters. * * @param array $named Named attributes parsed from shortcode. * @param string $type Shortcode type. * @param string $name_without_suffix Field name without booking-type suffix. * @param string $name_with_suffix Field name with booking-type suffix. * @param string $existing_atts Current attribute string (to avoid duplicates). * @param array &$options Legacy options (checked for id:... etc). * @return string Attribute fragment beginning with a leading space (or empty). */ private function build_named_atts_string( $named, $type, $name_without_suffix, $name_with_suffix, $existing_atts, &$options ) { $atts = ''; // We'll avoid duplicating attributes already present in $existing_atts. $has = function ( $needle ) use ( $existing_atts ) { return ( strpos( $existing_atts, ' ' . $needle . '="' ) !== false ); }; // id: prefer legacy id:... already handled elsewhere; otherwise allow id="..." if ( ! $has( 'id' ) && ! $this->shift_match( '%^id:([-0-9a-zA-Z_]+)$%', $options ) && isset( $named['id'] ) && $named['id'] !== '' ) { $atts .= ' id="' . esc_attr( $named['id'] . $this->current_resource_id ) . '"'; } // placeholder: prefer quoted named > legacy placeholder:token if ( ! $has( 'placeholder' ) && isset( $named['placeholder'] ) && $named['placeholder'] !== '' ) { $atts .= ' placeholder="' . esc_attr( $named['placeholder'] ) . '"'; } // class: merge legacy (collect_classes) with named class="" if ( isset( $named['class'] ) && $named['class'] !== '' ) { // We'll merge later in build_common_atts(); here we just mark presence to suppress duplicates. if ( ! $has( 'class' ) ) { $atts .= ' class="' . esc_attr( trim( $named['class'] ) ) . '"'; } } // Generic allowlist for simple inputs; these are safe + common. $generic_allow = array( 'maxlength', 'minlength', 'size', 'pattern', 'title', 'inputmode', 'list', 'step', 'min', 'max', 'dir', 'lang', 'tabindex' ); // Allow style only if opted-in. if ( $this->allow_inline_style( $type, $name_without_suffix ) ) { $generic_allow[] = 'style'; } foreach ( $named as $k => $v ) { $k = $this->sanitize_attr_name( $k, $type, $name_without_suffix ); if ( '' === $k ) { continue; } if ( in_array( $k, array( 'id', 'class', 'placeholder' ), true ) ) { continue; } $allow = ( in_array( $k, $generic_allow, true ) || str_starts_with( $k, 'aria-' ) || str_starts_with( $k, 'data-' ) ); if ( ! $allow || '' === $v || $has( $k ) ) { continue; } if ( 'style' === $k ) { $v = $this->sanitize_style_attr( $v ); if ( '' === $v ) { continue; // dropped if unsafe/empty. } } $atts .= ' ' . $k . '="' . esc_attr( $v ) . '"'; } return $atts; } /** * Append an HTML attribute only once to an attributes string. * * @param string &$atts Current attributes string (modified in place). * @param string $name Attribute name. * @param mixed $value Attribute value (will be escaped). * @return void */ private function append_att_once( &$atts, $name, $value ) { if ( strpos( $atts, ' ' . $name . '="' ) === false ) { $atts .= ' ' . $name . '="' . esc_attr( $value ) . '"'; } } /** * Whether inline style="" attributes are allowed for a given field/type (via filter). * * @param string $type Shortcode type. * @param string $name Field name without suffix. * @return bool True if style is allowed. */ private function allow_inline_style( $type = '', $name = '' ) { // Developers can enable per site / per field via filter. // Example: add_filter('wpbc_form_allow_inline_style_attr', fn($allow,$type,$name)=> true); return (bool) apply_filters( 'wpbc_form_allow_inline_style_attr', false, $type, $name, $this ); } /** * Sanitize a CSS style attribute value using WordPress KSES (or a conservative fallback). * * @param string $style Raw style attribute. * @return string Sanitized style (empty string if nothing allowed remains). */ private function sanitize_style_attr( $style ) { $style = trim( (string) $style ); if ( $style === '' ) { return ''; } // Prefer WP core’s CSS sanitizer if available. if ( function_exists( 'safecss_filter_attr' ) ) { $style = safecss_filter_attr( $style ); } else { // Very conservative fallback: drop dangerous patterns. // Disallow expression() and url(...) which can hide JS. $style = preg_replace( '/expression\s*\([^)]*\)/i', '', $style ); $style = preg_replace( '/url\s*\([^)]*\)/i', '', $style ); // Normalize spacing around semicolons. $style = preg_replace( '/\s*;\s*/', '; ', $style ); } return trim( $style ); } } /** * Allow inline style="" for selected shortcode types via the 'wpbc_form_allow_inline_style_attr' filter. * * @param bool $allow Current decision (default false). * @param string $type Shortcode type being rendered (e.g., 'text', 'select*', 'submit'). * @param string $name Field name (without booking-type suffix). * @return bool True to allow style for this field/type; otherwise previous $allow. */ function wpbc_form_allow_inline_style_attr_filter_free( $allow, $type, $name ) { $allow_shortcode_type_arr = array( 'text', 'email', 'coupon', 'time', 'textarea', 'select', 'selectbox', 'checkbox', 'radio', 'submit', 'text*', 'email*', 'coupon*', 'time*', 'textarea*', 'select*', 'selectbox*', 'checkbox*', 'radio' ); // 'text[*]?|email[*]?|coupon[*]?|time[*]?|textarea[*]?|select[*]?|selectbox[*]?|checkbox[*]?|radio[*]?|submit'; //. if ( in_array( $type, $allow_shortcode_type_arr, true ) ) { return true; } return $allow; } add_filter( 'wpbc_form_allow_inline_style_attr', 'wpbc_form_allow_inline_style_attr_filter_free', 10, 3 );