'' ), $raw_atts, 'wpvfb' ); if ( empty( $atts['id'] ) ) { return esc_html__( 'Please specify a form ID.', 'efe-visual-forms' ); } // Add-ons may handle their own display modes. $pre = apply_filters( 'wpvfb_pre_render_shortcode', null, $raw_atts, $this ); if ( null !== $pre ) { return self::kses( (string) $pre ); } $html = $this->render_form_markup( $atts['id'] ); if ( '' === $html ) { // Missing or inactive: visitors see nothing, admins see why. return current_user_can( 'manage_options' ) ? esc_html__( 'Form not found or inactive.', 'efe-visual-forms' ) : ''; } return self::kses( $html ); } private function load_form( $form_id ) { global $wpdb; $table_forms = $wpdb->prefix . 'wpvfb_forms'; // Inactive forms are not shown anywhere on the site. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin table (WPVFB_DB), no WordPress API for it. return $wpdb->get_row( $wpdb->prepare( 'SELECT name, fields, settings FROM %i WHERE id = %s AND is_active = 1', $table_forms, $form_id ) ); } /** * Form HTML for embedding (not yet passed through kses); '' when the form does not exist. * Enqueues the assets the form needs. * * @param string $form_id Form id. * @return string */ public function render_form_markup( $form_id ) { $key = (string) $form_id; if ( isset( $this->cache[ $key ] ) ) { $this->enqueue_assets( $this->cache[ $key ]['assets'] ); return $this->cache[ $key ]['html']; } $form = $this->load_form( $form_id ); if ( ! $form ) { return ''; } require_once WPVFB_PLUGIN_DIR . 'includes/render/class-fp-renderer.php'; $normalized = WPVFB_Schema::to_render_form( json_decode( (string) $form->fields, true ), json_decode( (string) $form->settings, true ) ); $normalized = apply_filters( 'wpvfb_normalized_form', $normalized, $form_id ); $class = apply_filters( 'wpvfb_fp_renderer_class', 'WPVFB_FP_Renderer' ); if ( ! is_string( $class ) || ! class_exists( $class ) || ( 'WPVFB_FP_Renderer' !== $class && ! is_subclass_of( $class, 'WPVFB_FP_Renderer' ) ) ) { $class = 'WPVFB_FP_Renderer'; } $safe_id = $this->safe_id( $form_id ); $renderer = new $class( array( 'id' => $safe_id, 'name' => $form->name, 'fields' => $normalized['fields'], 'settings' => $normalized['settings'], ), array( 'upload_limit_mb' => max( 1, (int) floor( wp_max_upload_size() / 1048576 ) ) ) ); $markup = $renderer->render_form(); // Template image paths (/images/...) ship inside the plugin. $base = WPVFB_PLUGIN_URL . 'dist/images/'; $markup = str_replace( array( 'src="/images/', "url('/images/", 'url("/images/', 'url(/images/', 'url('/images/', 'url("/images/' ), array( 'src="' . $base, "url('" . $base, 'url("' . $base, 'url(' . $base, 'url('' . $base, 'url("' . $base ), $markup ); $config = apply_filters( 'wpvfb_runtime_config', self::runtime_config( $normalized['fields'] ), $normalized['fields'], $normalized['settings'] ); $assets = array( 'css' => str_replace( array( "url('/images/", 'url("/images/', 'url(/images/' ), array( "url('" . $base, 'url("' . $base, 'url(' . $base ), $renderer->get_inline_css() ), 'rich_text' => ! empty( $renderer->needs_rich_text ), 'recaptcha' => self::recaptcha_key( $normalized['fields'] ), ); $assets = apply_filters( 'wpvfb_form_assets', $assets, $normalized, $form_id ); // .wpvfb-embed is the embed container (the scoped CSS targets it). data-wpvfb-config carries only what // the storefront runtime needs (types, required, logic) — never settings or secrets. $html = '
'; $this->cache[ $key ] = array( 'html' => $html, 'assets' => $assets ); $this->enqueue_assets( $assets ); return $html; } /** Per-field data for wpvfb-fp-runtime.js: type, required, admin-hidden, conditional logic, ancestors. */ private static function runtime_config( $fields ) { $out = array(); $walk = function ( $list, $parents ) use ( &$walk, &$out ) { foreach ( (array) $list as $field ) { if ( ! is_array( $field ) || ! isset( $field['id'] ) || ! is_scalar( $field['id'] ) || '' === (string) $field['id'] ) { continue; } $id = (string) $field['id']; $type = isset( $field['type'] ) && is_string( $field['type'] ) ? $field['type'] : 'text'; $item = array( 't' => $type ); if ( ! empty( $field['required'] ) ) { $item['r'] = 1; } if ( isset( $field['visibility'] ) && in_array( $field['visibility'], array( 'hidden', 'administrative' ), true ) ) { $item['h'] = 1; } $logic = WPVFB_Logic::normalize( $field ); if ( $logic ) { $item['l'] = $logic; } if ( $parents ) { $item['p'] = $parents; } $out[ $id ] = apply_filters( 'wpvfb_runtime_field_config', $item, $field, $parents ); if ( 'grid' === $type && isset( $field['columns'] ) && is_array( $field['columns'] ) ) { $children = array(); foreach ( $field['columns'] as $col ) { if ( is_array( $col ) ) { $children = array_merge( $children, $col ); } } $walk( $children, array_merge( $parents, array( $id ) ) ); } } }; $walk( $fields, array() ); return array( 'fields' => (object) $out ); } /** * Site key for Google's api.js "render" parameter: the v3 site key when a v3 captcha is used, "explicit" for * v2 widgets, '' when the form has no captcha with a site key. */ private static function recaptcha_key( $fields ) { $found = ''; foreach ( (array) $fields as $f ) { if ( ! is_array( $f ) ) { continue; } if ( isset( $f['type'] ) && 'captcha' === $f['type'] && ! empty( $f['siteKey'] ) && is_string( $f['siteKey'] ) ) { if ( isset( $f['captchaType'] ) && 'v3' === $f['captchaType'] ) { return sanitize_text_field( $f['siteKey'] ); } $found = 'explicit'; } if ( isset( $f['type'] ) && 'grid' === $f['type'] && isset( $f['columns'] ) && is_array( $f['columns'] ) ) { foreach ( $f['columns'] as $col ) { $key = is_array( $col ) ? self::recaptcha_key( $col ) : ''; if ( '' !== $key && 'explicit' !== $key ) { return $key; } if ( 'explicit' === $key ) { $found = 'explicit'; } } } } return $found; } /** * Pre-renders the forms embedded in the current post while styles can still go to , so classic * themes do not show the form unstyled. Forms rendered later (widgets, page builders) get their styles in * the footer through the same handle. */ public function enqueue_head_styles() { $post = get_queried_object(); if ( ! ( $post instanceof WP_Post ) || '' === $post->post_content ) { return; } if ( ! has_shortcode( $post->post_content, 'wpvfb' ) ) { return; } $pattern = get_shortcode_regex( array( 'wpvfb' ) ); if ( preg_match_all( '/' . $pattern . '/s', $post->post_content, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $m ) { $atts = shortcode_parse_atts( $m[3] ); if ( is_array( $atts ) && ! empty( $atts['id'] ) && null === apply_filters( 'wpvfb_pre_render_shortcode', null, $atts, $this ) ) { $this->render_form_markup( $atts['id'] ); } } } $this->register_assets(); wp_enqueue_style( 'wpvfb-fp-scoped' ); wp_enqueue_style( 'wpvfb-fp-extensions' ); } /** Registers every storefront handle once. */ public function register_assets() { if ( wp_script_is( 'wpvfb-fp-runtime', 'registered' ) ) { return; } $url = WPVFB_PLUGIN_URL; $dir = WPVFB_PLUGIN_DIR; $ver = function ( $file ) use ( $dir ) { return file_exists( $dir . $file ) ? WPVFB_VERSION . '.' . filemtime( $dir . $file ) : WPVFB_VERSION; }; wp_register_style( 'wpvfb-fontawesome', $url . 'assets/vendor/fontawesome/css/all.min.css', array(), '5.15.4' ); wp_register_style( 'wpvfb-fp-scoped', $url . 'assets/css/wpvfb-fp-scoped.css', array( 'wpvfb-fontawesome' ), $ver( 'assets/css/wpvfb-fp-scoped.css' ) ); wp_register_style( 'wpvfb-fp-extensions', $url . 'assets/css/wpvfb-fp-extensions.css', array( 'wpvfb-fp-scoped' ), $ver( 'assets/css/wpvfb-fp-extensions.css' ) ); // Carries the per-form stylesheet text added with wp_add_inline_style(). wp_register_style( self::INLINE_STYLE_HANDLE, false, array( 'wpvfb-fp-extensions' ), WPVFB_VERSION ); wp_register_style( 'wpvfb-quill', $url . 'assets/vendor/quill/quill.snow.css', array(), '2.0.3' ); wp_register_script( 'wpvfb-quill', $url . 'assets/vendor/quill/quill.js', array(), '2.0.3', true ); wp_register_script( 'wpvfb-fp-runtime', $url . 'assets/js/wpvfb-fp-runtime.js', array(), $ver( 'assets/js/wpvfb-fp-runtime.js' ), true ); wp_localize_script( 'wpvfb-fp-runtime', 'wpvfbFrontend', $this->frontend_config() ); wp_register_script( 'wpvfb-recaptcha', $url . 'assets/js/wpvfb-recaptcha.js', array( 'wpvfb-fp-runtime' ), $ver( 'assets/js/wpvfb-recaptcha.js' ), true ); do_action( 'wpvfb_register_storefront_assets' ); } /** * Late escaping of the per-form stylesheet before it is handed to wp_add_inline_style(): no tags, no "<" at all * (so "" except the CSS child combinator written with spaces (" > "), no * comments, @import or script-like constructs. Values were already validated by WPVFB_Schema::sanitize_style_values(). * * @param string $css Stylesheet text. * @return string */ public static function sanitize_css( $css ) { $css = wp_strip_all_tags( (string) $css ); $css = str_replace( '<', '', $css ); $css = preg_replace( '/(?|>(?!\s)/', '', $css ); $css = preg_replace( '#/\*.*?\*/#s', '', $css ); $css = preg_replace( '/@import\b[^;]*;?|expression\s*\(|javascript:|vbscript:|behavior\s*:|-moz-binding/i', '', $css ); return trim( $css ); } /** Enqueues the handles one rendered form needs and attaches its stylesheet text. */ private function enqueue_assets( $assets ) { $this->register_assets(); wp_enqueue_style( self::INLINE_STYLE_HANDLE ); if ( ! empty( $assets['css'] ) ) { // Same form twice on a page: its rules are added once. $hash = md5( $assets['css'] ); if ( empty( $this->added[ $hash ] ) ) { $this->added[ $hash ] = true; wp_add_inline_style( self::INLINE_STYLE_HANDLE, self::sanitize_css( $assets['css'] ) ); } } if ( ! empty( $assets['rich_text'] ) ) { wp_enqueue_style( 'wpvfb-quill' ); wp_enqueue_script( 'wpvfb-quill' ); } wp_enqueue_script( 'wpvfb-fp-runtime' ); if ( ! empty( $assets['recaptcha'] ) && ! wp_script_is( 'wpvfb-google-recaptcha', 'enqueued' ) ) { // Google reCAPTCHA service (documented in readme.txt, "External services"). wp_enqueue_script( 'wpvfb-recaptcha' ); wp_enqueue_script( 'wpvfb-google-recaptcha', add_query_arg( array( 'onload' => 'wpvfbRecaptchaReady', 'render' => rawurlencode( $assets['recaptcha'] ) ), 'https://www.google.com/recaptcha/api.js' ), array( 'wpvfb-recaptcha' ), null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- remote service URL, versioned by Google. true ); } do_action( 'wpvfb_enqueue_form_assets', $assets ); } /** @var array Inline text already attached this request. */ private $added = array(); /** Decoded settings, or null when the form does not exist. */ public function get_form_settings( $form_id ) { $form = $this->load_form( $form_id ); if ( ! $form ) { return null; } $settings = json_decode( (string) $form->settings, true ); return is_array( $settings ) ? $settings : array(); } /** Form and field ids are UUID-like; anything else is stripped before it reaches CSS selectors or attributes. */ private function safe_id( $id ) { return preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $id ); } /** Localized data for wpvfb-fp-runtime.js. */ private function frontend_config() { return array( 'submitUrl' => esc_url_raw( rest_url( WPVFB_API::REST_NAMESPACE . '/submit' ) ), 'tokenUrl' => esc_url_raw( rest_url( WPVFB_API::REST_NAMESPACE . '/submit-token' ) ), // Only used by login-gated forms, which must authenticate the visitor (logged-in pages are not page-cached). 'restNonce' => is_user_logged_in() ? wp_create_nonce( 'wp_rest' ) : '', 'loginUrl' => esc_url_raw( wp_login_url() ), 'registerUrl' => get_option( 'users_can_register' ) ? esc_url_raw( wp_registration_url() ) : '', 'i18n' => array( 'submitting' => __( 'Submitting...', 'efe-visual-forms' ), 'success' => __( 'Thank you! Your submission has been saved.', 'efe-visual-forms' ), 'error' => __( 'Please check your inputs and try again.', 'efe-visual-forms' ), 'serverError' => __( 'Server error. Please try again.', 'efe-visual-forms' ), 'expired' => __( 'This offer has officially expired!', 'efe-visual-forms' ), 'required' => __( 'This field is required', 'efe-visual-forms' ), 'selectOne' => __( 'Please select an option', 'efe-visual-forms' ), 'selectMany' => __( 'Please select at least one option', 'efe-visual-forms' ), 'invalidEmail' => __( 'Please enter a valid email address', 'efe-visual-forms' ), 'invalidUrl' => __( 'Please enter a valid URL', 'efe-visual-forms' ), 'invalidPhone' => __( 'Please enter a valid phone number', 'efe-visual-forms' ), 'invalidNumber' => __( 'Please enter a valid number', 'efe-visual-forms' ), 'submitFailed' => __( 'Submission failed', 'efe-visual-forms' ), 'genericError' => __( 'Something went wrong. Please try again.', 'efe-visual-forms' ), 'retake' => __( 'Retake quiz', 'efe-visual-forms' ), ), ); } // ------------------------------------------------------------------ // Output escaping // ------------------------------------------------------------------ /** * Escapes form HTML with an explicit allow-list: form controls, layout elements, inline SVG icons and * data-* / aria-* attributes. Inline event handlers, scripts and style elements are never allowed. * * @param string $html Markup produced by the renderer. * @return string */ public static function kses( $html ) { add_filter( 'safe_style_css', array( __CLASS__, 'safe_style_properties' ) ); add_filter( 'safecss_filter_attr_allow_css', array( __CLASS__, 'allow_css_declaration' ), 10, 2 ); $out = wp_kses( self::encode_attribute_brackets( $html ), self::allowed_html(), array_merge( wp_allowed_protocols(), array( 'data' ) ) ); remove_filter( 'safecss_filter_attr_allow_css', array( __CLASS__, 'allow_css_declaration' ), 10 ); remove_filter( 'safe_style_css', array( __CLASS__, 'safe_style_properties' ) ); return $out; } /** * Encodes "<" and ">" inside quoted attribute values (equivalent HTML). wp_kses() and wptexturize() treat a * raw ">" as the end of the tag, which would cut such attributes. * * @param string $html Markup. * @return string */ private static function encode_attribute_brackets( $html ) { return preg_replace_callback( '/<[a-zA-Z][^\s\/>]*(?:\s+[^\s"\'>\/=]+(?:\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s"\'=<>`]+))?)*\s*\/?>/s', function ( $m ) { return preg_replace_callback( '/=\s*(?:"[^"]*"|\'[^\']*\')/', function ( $v ) { return str_replace( array( '<', '>' ), array( '<', '>' ), $v[0] ); }, $m[0] ); }, (string) $html ); } /** * Allowed tags and attributes for form output (filterable with "wpvfb_allowed_html"). * * @return array */ public static function allowed_html() { static $allowed = null; if ( null !== $allowed ) { return $allowed; } $global = array( 'id' => true, 'class' => true, 'style' => true, 'title' => true, 'role' => true, 'tabindex' => true, 'hidden' => true, 'lang' => true, 'dir' => true, 'aria-label' => true, 'aria-labelledby' => true, 'aria-describedby' => true, 'aria-hidden' => true, 'aria-expanded' => true, 'aria-controls' => true, 'aria-live' => true, 'aria-required' => true, 'aria-invalid' => true, 'aria-checked' => true, 'aria-selected' => true, 'aria-pressed' => true, 'aria-valuenow' => true, 'aria-valuemin' => true, 'aria-valuemax' => true, 'aria-current' => true, 'data-*' => true, ); $field = array( 'name' => true, 'value' => true, 'type' => true, 'placeholder' => true, 'required' => true, 'disabled' => true, 'readonly' => true, 'checked' => true, 'selected' => true, 'multiple' => true, 'min' => true, 'max' => true, 'step' => true, 'minlength' => true, 'maxlength' => true, 'pattern' => true, 'autocomplete' => true, 'inputmode' => true, 'accept' => true, 'size' => true, 'rows' => true, 'cols' => true, 'for' => true, 'form' => true, 'novalidate' => true, 'action' => true, 'method' => true, 'enctype' => true, 'label' => true, 'list' => true, 'spellcheck' => true, 'autofocus' => true, ); $media = array( 'src' => true, 'alt' => true, 'width' => true, 'height' => true, 'loading' => true, 'decoding' => true, 'srcset' => true, 'sizes' => true ); $link = array( 'href' => true, 'target' => true, 'rel' => true ); $iframe = array( 'src' => true, 'width' => true, 'height' => true, 'allow' => true, 'allowfullscreen' => true, 'loading' => true, 'referrerpolicy' => true, 'frameborder' => true ); $tags = array( 'div', 'span', 'p', 'br', 'hr', 'strong', 'b', 'em', 'i', 'u', 's', 'small', 'sub', 'sup', 'mark', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'caption', 'colgroup', 'col', 'section', 'article', 'header', 'footer', 'nav', 'figure', 'figcaption', 'fieldset', 'legend', 'form', 'label', 'input', 'select', 'option', 'optgroup', 'textarea', 'button', 'output', 'datalist', 'progress', 'meter', 'a', 'img', 'picture', 'source', 'video', 'audio', 'iframe', 'canvas', ); $allowed = array(); foreach ( $tags as $tag ) { $allowed[ $tag ] = $global + $field; } foreach ( array( 'img', 'picture', 'source', 'video', 'audio', 'canvas' ) as $tag ) { $allowed[ $tag ] += $media; } foreach ( array( 'video', 'audio' ) as $tag ) { $allowed[ $tag ] += array( 'controls' => true, 'autoplay' => true, 'muted' => true, 'loop' => true, 'playsinline' => true, 'poster' => true ); } $allowed['a'] += $link; $allowed['iframe'] += $iframe; foreach ( array( 'td', 'th' ) as $tag ) { $allowed[ $tag ] += array( 'colspan' => true, 'rowspan' => true, 'scope' => true, 'headers' => true ); } $allowed['col'] += array( 'span' => true ); $allowed['colgroup'] += array( 'span' => true ); // Inline SVG icons: presentation attributes only. $svg = array_fill_keys( array( 'class', 'style', 'width', 'height', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'stroke-miterlimit', 'fill-opacity', 'fill-rule', 'clip-rule', 'clip-path', 'opacity', 'transform', 'xmlns', 'aria-hidden', 'role', 'focusable', 'preserveaspectratio', 'id', 'd', 'points', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'offset', 'stop-color', 'stop-opacity', 'gradientunits', 'gradienttransform', 'data-*', ), true ); foreach ( array( 'svg', 'g', 'path', 'polyline', 'polygon', 'circle', 'ellipse', 'rect', 'line', 'defs', 'lineargradient', 'radialgradient', 'stop', 'title', 'clippath', 'use' ) as $tag ) { $allowed[ $tag ] = $svg; } $allowed = apply_filters( 'wpvfb_allowed_html', $allowed ); return $allowed; } /** CSS properties the form markup uses on top of WordPress' safe list (kses only). */ public static function safe_style_properties( $props ) { return array_merge( $props, array( 'display', 'position', 'top', 'right', 'bottom', 'left', 'inset', 'z-index', 'float', 'clear', 'transform', 'transform-origin', 'transition', 'transition-property', 'transition-duration', 'animation', 'opacity', 'visibility', 'overflow', 'overflow-x', 'overflow-y', 'cursor', 'pointer-events', 'user-select', 'box-sizing', 'box-shadow', 'outline', 'outline-offset', 'object-fit', 'object-position', 'accent-color', 'appearance', '-webkit-appearance', '-moz-appearance', 'resize', 'white-space', 'word-break', 'overflow-wrap', 'text-overflow', 'text-shadow', 'text-transform', 'letter-spacing', 'line-height', 'vertical-align', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis', 'flex-flow', 'align-items', 'align-self', 'align-content', 'justify-content', 'justify-items', 'justify-self', 'gap', 'row-gap', 'column-gap', 'order', 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row', 'grid-area', 'grid-template-areas', 'grid-template', 'clip', 'clip-path', 'grid-auto-flow', 'grid-auto-rows', 'grid-auto-columns', 'place-items', 'aspect-ratio', 'min-width', 'max-width', 'min-height', 'max-height', 'backdrop-filter', '-webkit-backdrop-filter', 'filter', 'background-clip', '-webkit-background-clip', '-webkit-text-fill-color', 'background-blend-mode', 'mix-blend-mode', 'isolation', 'margin-block', 'margin-inline', 'padding-block', 'padding-inline', 'inset-inline', 'inset-block', 'scroll-behavior', 'touch-action', 'will-change', 'content', 'fill', 'stroke', 'list-style', 'list-style-type', 'list-style-position', 'border-collapse', 'border-spacing', 'table-layout', 'font-feature-settings', 'font-variant', '-webkit-font-smoothing', '-moz-osx-font-smoothing', 'text-rendering', 'text-decoration-color', 'text-decoration-thickness', 'text-underline-offset', 'caret-color', 'tab-size', '--checkbox-selected-color', '--checkbox-unselected-color', '--checkbox-svg-icon', ) ); } /** * WordPress drops declarations that still contain "(" after its own function checks (e.g. rgba(), * translateX(), linear-gradient() in some properties). The values were escaped by the renderer; only refuse * the patterns that can run code or load remote content. */ public static function allow_css_declaration( $allow, $css_test_string ) { if ( $allow ) { return true; } $lower = strtolower( (string) $css_test_string ); foreach ( array( 'expression', 'javascript:', 'vbscript:', 'behavior', '-moz-binding', '@import', '\\', '/*', '<', '>', '{', '}' ) as $bad ) { if ( false !== strpos( $lower, $bad ) ) { return false; } } return true; } }