PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/ai-form-builder/field-mapping.php +186 -17 2.7.1 → 2.12.8 View file →
@@ -9,8 +9,9 @@
9 9 namespace SRFM\Inc\AI_Form_Builder;
10 10
11 11 use SRFM\Inc\Helper;
12 12 use SRFM\Inc\Traits\Get_Instance;
13 +use WP_Error;
13 14
14 15 // Exit if accessed directly.
15 16 if ( ! defined( 'ABSPATH' ) ) {
16 17 exit;
@@ -25,9 +26,9 @@
25 26 /**
26 27 * Generate Gutenberg Fields from AI data.
27 28 *
28 29 * @param \WP_REST_Request $request Full details about the request.
29 - * @return string
30 + * @return string|WP_Error
30 31 */
31 32 public static function generate_gutenberg_fields_from_questions( $request ) {
32 33
33 34 // Get params from request.
@@ -33,27 +34,46 @@
33 34 // Get params from request.
34 35 $params = $request->get_params();
35 36
36 37 // check parama is empty or not and is an array and consist form_data key.
37 - if ( empty( $params ) || ! is_array( $params ) || ! isset( $params['form_data'] ) || 0 === count( $params['form_data'] ) ) {
38 - return '';
38 + // count() is guarded by is_array(): a non-array form_data is a TypeError in PHP 8,
39 + // and this endpoint is reachable with any JSON value. It falls through to the
40 + // invalid_form_data check below instead.
41 + if ( empty( $params ) || ! is_array( $params ) || ! isset( $params['form_data'] )
42 + || ( is_array( $params['form_data'] ) && 0 === count( $params['form_data'] ) ) ) {
43 + return new WP_Error(
44 + 'srfm_ai_mapping_missing_form_data',
45 + __( 'The AI form data is missing. Please try again.', 'sureforms' ),
46 + [ 'status' => 400 ]
47 + );
39 48 }
40 49
41 50 // Get questions from form data.
42 51 $form_data = $params['form_data'];
43 52 if ( empty( $form_data ) || ! is_array( $form_data ) ) {
44 - return '';
53 + return new WP_Error(
54 + 'srfm_ai_mapping_invalid_form_data',
55 + __( 'The AI form data is not in the expected format.', 'sureforms' ),
56 + [ 'status' => 400 ]
57 + );
45 58 }
46 59
47 - $form = $form_data['form'];
60 + $form = $form_data['form'] ?? null;
48 61 if ( empty( $form ) || ! is_array( $form ) ) {
49 - return '';
62 + return new WP_Error(
63 + 'srfm_ai_mapping_missing_form',
64 + __( 'The AI response did not include a form. Please try again.', 'sureforms' ),
65 + [ 'status' => 400 ]
66 + );
50 67 }
51 68
52 - $form_fields = $form['formFields'];
53 - // if questions is empty then return empty string.
54 - if ( empty( $form_fields ) || ! is_array( $form ) ) {
55 - return '';
69 + $form_fields = $form['formFields'] ?? null;
70 + if ( empty( $form_fields ) || ! is_array( $form_fields ) ) {
71 + return new WP_Error(
72 + 'srfm_ai_mapping_missing_form_fields',
73 + __( 'The AI was unable to generate form fields. Please try again.', 'sureforms' ),
74 + [ 'status' => 400 ]
75 + );
56 76 }
57 77
58 78 // Initialize post content string.
59 79 $post_content = '';
@@ -68,9 +88,13 @@
68 88 foreach ( $form_fields as $question ) {
69 89
70 90 // Check if question is empty then continue to next question.
71 91 if ( empty( $question ) || ! is_array( $question ) ) {
72 - return '';
92 + return new WP_Error(
93 + 'srfm_ai_mapping_invalid_field',
94 + __( 'The AI returned a malformed form field. Please try again.', 'sureforms' ),
95 + [ 'status' => 400 ]
96 + );
73 97 }
74 98
75 99 // Initialize common attributes.
76 100 $common_attributes = [
@@ -88,8 +112,39 @@
88 112 'slug' => isset( $question['slug'] ) ? sanitize_text_field( $question['slug'] ) : '',
89 113 ]
90 114 );
91 115
116 + // Forward `placeholder` to the block attrs. Every input-like
117 + // block (`input`, `email`, `url`, `phone`, `number`,
118 + // `textarea`, `dropdown`) declares a `placeholder` attribute
119 + // in its block.json; without this passthrough the value is
120 + // silently dropped by the mapper even when the caller (AI,
121 + // MCP, or the HTML-form converter) supplied it.
122 + if ( isset( $question['placeholder'] ) && is_string( $question['placeholder'] ) && '' !== $question['placeholder'] ) {
123 + // Bound the placeholder to 500 chars: other string fields
124 + // in this mapper are implicitly bounded by their upstream
125 + // schema, but `placeholder` lands here from three call
126 + // sites (AI, MCP, HTML converter) and a pathological
127 + // caller could push a multi-MB string into the block's
128 + // `_srfm_*` post meta. `wp_html_excerpt` strips HTML
129 + // first, then truncates safely on word boundaries.
130 + $merged_attributes['placeholder'] = wp_html_excerpt( sanitize_text_field( $question['placeholder'] ), 500 );
131 + }
132 +
133 + // Forward `className` (Additional CSS Class) to the block attrs.
134 + // Field blocks inherit core's className support and render it onto the
135 + // field wrapper (see inc/fields/base.php::set_properties()). Lands from
136 + // multiple callers (AI, MCP, HTML converter), so sanitize each token.
137 + if ( isset( $question['className'] ) && is_string( $question['className'] ) && '' !== $question['className'] ) {
138 + $classes = preg_split( '/\s+/', trim( $question['className'] ) );
139 + if ( is_array( $classes ) ) {
140 + $clean = implode( ' ', array_filter( array_map( 'sanitize_html_class', $classes ) ) );
141 + if ( '' !== $clean ) {
142 + $merged_attributes['className'] = $clean;
143 + }
144 + }
145 + }
146 +
92 147 // Apply filter to modify field type.
93 148 $field_type = apply_filters( 'srfm_ai_field_modify_field_type', $question['fieldType'], $question, $is_conversational, $form_type );
94 149
95 150 // Determine field type based on field_type.
@@ -108,25 +163,100 @@
108 163 case 'phone':
109 164 case 'payment':
110 165 // if payment block then map payment specific attributes.
111 166 if ( 'payment' === $field_type ) {
167 + // Amount-unit convention (do not change without auditing the full
168 + // chain): the AI prompt schema describes fixedAmount / oneTimeFixedAmount
169 + // / subscriptionFixedAmount in MAJOR units (dollars/euros/etc.) using
170 + // dollar-magnitude examples (e.g. 99, 1500). All downstream layers
171 + // agree: block attrs and stored block_config keep the value in major
172 + // units, frontend JS multiplies by 100 only at the boundary when posting
173 + // to create_payment_intent, and the server divides it back via
174 + // Stripe_Helper::amount_from_stripe_format() before validating against
175 + // the stored fixed_amount. Stripe API itself is the only consumer that
176 + // expects minor units and it is fed the JS-multiplied value. Reviewers:
177 + // do not flag a "cents vs dollars ambiguity" here — the convention is
178 + // consistent end-to-end, and adding a unit declaration to the AI schema
179 + // would actually break the existing pipeline.
180 + //
181 + // Default-amount convention (do not change without auditing every
182 + // callsite): the fallback `10` used when the AI omits fixedAmount /
183 + // oneTimeFixedAmount / subscriptionFixedAmount is the same starter
184 + // value that block.json sets when an admin manually adds a payment
185 + // block in the Gutenberg editor. payment-markup.php and
186 + // field-validation.php apply the same default. AI-generated forms
187 + // therefore behave identically to manually-built forms when an amount
188 + // is missing — admin reviews the form preview and adjusts before
189 + // publishing. The schema marks these three amounts as `required`, so
190 + // in practice this fallback only fires for malformed AI responses;
191 + // changing it to 0 would make the manual-editor UX worse without
192 + // closing any real revenue-loss vector. Reviewers: do not flag the
193 + // `10` default here as a hidden charge — it is the project-wide
194 + // payment-block starter value.
195 + //
196 + // Update-flow caveat (pre-existing, not specific to "both" mode):
197 + // generate_gutenberg_fields_from_questions() is also called by the
198 + // update-form ability (inc/abilities/forms/update-form.php) which
199 + // regenerates the entire post_content from the AI's input. There is
200 + // no merge with the form's currently-saved attributes — every field
201 + // type's default-on-omit behavior applies. If an AI partial update
202 + // omits a field attribute (e.g. a previously-saved subscriptionFixedAmount
203 + // of $15), the default kicks in and overwrites the saved value. This
204 + // is a long-standing characteristic of the update flow, affecting all
205 + // fields equally; it is not a regression introduced by the "both"
206 + // payment-type work and should be addressed (if at all) by teaching
207 + // generate_gutenberg_fields_from_questions to merge with existing block
208 + // attrs — a broader refactor outside this scope. Reviewers: do not
209 + // flag this as a payment-specific bug.
210 + //
211 + // Schema "required" scope (sureforms-ai-templates/payment.json):
212 + // the JSON schema lists every payment property — including all 11
213 + // "both"-mode attrs — in a single flat `required` array applied to
214 + // every payment field, not scoped per paymentType. This is a
215 + // constraint of OpenAI's strict structured output mode: when
216 + // `additionalProperties: false` is set, every property must also
217 + // appear in `required`. The per-property `description` strings tell
218 + // the model to emit empty strings / 0 for inapplicable modes (e.g.
219 + // `oneTimeLabel: ''` when paymentType='one-time'). The mapping below
220 + // only reads those attrs when paymentType='both', so empty values
221 + // for other modes are silently and correctly dropped — there is no
222 + // silent conflict. Reviewers: do not flag the flat `required` list
223 + // as a scoping bug; it is how OpenAI strict mode works.
224 + $amount_types = [ 'fixed', 'variable', 'user-choice' ];
225 + $intervals = [ 'day', 'week', 'month', 'quarter', 'year' ];
226 +
112 227 $merged_attributes['customerNameField'] = isset( $question['customerNameField'] ) ? sanitize_text_field( $question['customerNameField'] ) : '';
113 228 $merged_attributes['customerEmailField'] = isset( $question['customerEmailField'] ) ? sanitize_text_field( $question['customerEmailField'] ) : '';
114 - $merged_attributes['paymentType'] = isset( $question['paymentType'] ) && in_array( $question['paymentType'], [ 'one-time', 'subscription' ], true ) ? sanitize_text_field( $question['paymentType'] ) : 'one-time';
229 + $merged_attributes['paymentType'] = isset( $question['paymentType'] ) && in_array( $question['paymentType'], [ 'one-time', 'subscription', 'both' ], true ) ? sanitize_text_field( $question['paymentType'] ) : 'one-time';
115 230 $merged_attributes['subscriptionPlan'] = isset( $question['subscriptionPlan'] ) && is_array( $question['subscriptionPlan'] ) ? [
116 231 'name' => isset( $question['subscriptionPlan']['name'] ) ? sanitize_text_field( $question['subscriptionPlan']['name'] ) : 'Subscription Plan',
117 - 'interval' => isset( $question['subscriptionPlan']['interval'] ) && in_array( $question['subscriptionPlan']['interval'], [ 'day', 'week', 'month', 'year' ], true ) ? sanitize_text_field( $question['subscriptionPlan']['interval'] ) : 'month',
118 - 'billingCycles' => isset( $question['subscriptionPlan']['billingCycles'] ) ? sanitize_text_field( $question['subscriptionPlan']['billingCycles'] ) : 'ongoing',
232 + 'interval' => isset( $question['subscriptionPlan']['interval'] ) && in_array( $question['subscriptionPlan']['interval'], $intervals, true ) ? sanitize_text_field( $question['subscriptionPlan']['interval'] ) : 'month',
233 + 'billingCycles' => isset( $question['subscriptionPlan']['billingCycles'] ) ? ( is_numeric( $question['subscriptionPlan']['billingCycles'] ) ? intval( $question['subscriptionPlan']['billingCycles'] ) : sanitize_text_field( $question['subscriptionPlan']['billingCycles'] ) ) : 'ongoing',
119 234 ] : [
120 235 'name' => 'Subscription Plan',
121 236 'interval' => 'month',
122 237 'billingCycles' => 'ongoing',
123 238 ];
124 - $merged_attributes['amountType'] = isset( $question['amountType'] ) && in_array( $question['amountType'], [ 'fixed', 'variable', 'user-choice' ], true ) ? sanitize_text_field( $question['amountType'] ) : 'fixed';
239 + $merged_attributes['amountType'] = isset( $question['amountType'] ) && in_array( $question['amountType'], $amount_types, true ) ? sanitize_text_field( $question['amountType'] ) : 'fixed';
125 240 $merged_attributes['fixedAmount'] = isset( $question['fixedAmount'] ) && is_numeric( $question['fixedAmount'] ) ? floatval( $question['fixedAmount'] ) : 10;
126 241 $merged_attributes['minimumAmount'] = isset( $question['minimumAmount'] ) && is_numeric( $question['minimumAmount'] ) ? floatval( $question['minimumAmount'] ) : 0;
127 242 $merged_attributes['amountLabel'] = isset( $question['amountLabel'] ) ? sanitize_text_field( $question['amountLabel'] ) : 'Enter Amount';
128 243 $merged_attributes['variableAmountField'] = isset( $question['variableAmountField'] ) ? sanitize_text_field( $question['variableAmountField'] ) : '';
244 +
245 + // "Both" mode attributes — admins configure one-time AND subscription in the same block.
246 + if ( 'both' === $merged_attributes['paymentType'] ) {
247 + $merged_attributes['oneTimeLabel'] = isset( $question['oneTimeLabel'] ) ? sanitize_text_field( $question['oneTimeLabel'] ) : 'One-Time Payment';
248 + $merged_attributes['subscriptionLabel'] = isset( $question['subscriptionLabel'] ) ? sanitize_text_field( $question['subscriptionLabel'] ) : 'Subscription';
249 + $merged_attributes['defaultPaymentChoice'] = isset( $question['defaultPaymentChoice'] ) && in_array( $question['defaultPaymentChoice'], [ 'one-time', 'subscription' ], true ) ? sanitize_text_field( $question['defaultPaymentChoice'] ) : 'one-time';
250 + $merged_attributes['oneTimeAmountType'] = isset( $question['oneTimeAmountType'] ) && in_array( $question['oneTimeAmountType'], $amount_types, true ) ? sanitize_text_field( $question['oneTimeAmountType'] ) : 'fixed';
251 + $merged_attributes['oneTimeFixedAmount'] = isset( $question['oneTimeFixedAmount'] ) && is_numeric( $question['oneTimeFixedAmount'] ) ? floatval( $question['oneTimeFixedAmount'] ) : 10;
252 + $merged_attributes['oneTimeMinimumAmount'] = isset( $question['oneTimeMinimumAmount'] ) && is_numeric( $question['oneTimeMinimumAmount'] ) ? floatval( $question['oneTimeMinimumAmount'] ) : 0;
253 + $merged_attributes['oneTimeVariableAmountField'] = isset( $question['oneTimeVariableAmountField'] ) ? sanitize_text_field( $question['oneTimeVariableAmountField'] ) : '';
254 + $merged_attributes['subscriptionAmountType'] = isset( $question['subscriptionAmountType'] ) && in_array( $question['subscriptionAmountType'], $amount_types, true ) ? sanitize_text_field( $question['subscriptionAmountType'] ) : 'fixed';
255 + $merged_attributes['subscriptionFixedAmount'] = isset( $question['subscriptionFixedAmount'] ) && is_numeric( $question['subscriptionFixedAmount'] ) ? floatval( $question['subscriptionFixedAmount'] ) : 10;
256 + $merged_attributes['subscriptionMinimumAmount'] = isset( $question['subscriptionMinimumAmount'] ) && is_numeric( $question['subscriptionMinimumAmount'] ) ? floatval( $question['subscriptionMinimumAmount'] ) : 0;
257 + $merged_attributes['subscriptionVariableAmountField'] = isset( $question['subscriptionVariableAmountField'] ) ? sanitize_text_field( $question['subscriptionVariableAmountField'] ) : '';
258 + }
129 259 }
130 260
131 261 // Handle specific attributes for certain fields.
132 262 if ( 'dropdown' === $field_type && ! empty( $question['fieldOptions'] ) && is_array( $question['fieldOptions'] ) &&
@@ -131,9 +261,13 @@
131 261 // Handle specific attributes for certain fields.
132 262 if ( 'dropdown' === $field_type && ! empty( $question['fieldOptions'] ) && is_array( $question['fieldOptions'] ) &&
133 263 ! empty( $question['fieldOptions'][0]['label'] )
134 264 ) {
135 - $merged_attributes['options'] = $question['fieldOptions'];
265 + // Defense-in-depth: although the upstream middleware is
266 + // trusted and these endpoints are capability-gated,
267 + // strings flow into Gutenberg block markup so we run
268 + // the user-facing fields through sanitize_text_field.
269 + $merged_attributes['options'] = self::sanitize_field_options( $question['fieldOptions'] );
136 270
137 271 if ( isset( $question['showValues'] ) ) {
138 272 $merged_attributes['showValues'] = filter_var( $question['showValues'], FILTER_VALIDATE_BOOLEAN );
139 273 }
@@ -158,9 +292,11 @@
158 292 }
159 293
160 294 // Set options if they are valid.
161 295 if ( ! empty( $question['fieldOptions'][0]['optionTitle'] ) ) {
162 - $merged_attributes['options'] = $question['fieldOptions'];
296 + // Same defense-in-depth sanitization as the
297 + // dropdown branch above.
298 + $merged_attributes['options'] = self::sanitize_field_options( $question['fieldOptions'] );
163 299 }
164 300
165 301 // Determine vertical layout based on icons.
166 302 if ( ! empty( $merged_attributes['options'] ) ) {
@@ -206,8 +342,9 @@
206 342 case 'upload':
207 343 case 'hidden':
208 344 case 'rating':
209 345 case 'signature':
346 + case 'nps':
210 347 // If pro version is not active then do not add pro fields.
211 348 if ( ! defined( 'SRFM_PRO_VER' ) ) {
212 349 break;
213 350 }
@@ -322,7 +459,39 @@
322 459 }
323 460 }
324 461
325 462 return apply_filters( 'srfm_ai_form_builder_post_content', $post_content, $is_conversational, $form_type );
463 + }
464 +
465 + /**
466 + * Sanitize the user-facing strings on each entry of the AI-generated
467 + * fieldOptions array before they are merged into block attributes.
468 + *
469 + * Defense-in-depth: the middleware is trusted today, but these strings
470 + * are serialized into Gutenberg block markup. Running each string field
471 + * through sanitize_text_field() prevents stored-content injection if
472 + * the upstream ever returns reflected user content. Non-string fields
473 + * (icon class names, booleans) are left untouched.
474 + *
475 + * @param array<int, array<string, mixed>> $options Raw fieldOptions array.
476 + * @since 2.8.2
477 + * @return array<int, array<string, mixed>> Sanitized options.
478 + */
479 + private static function sanitize_field_options( $options ) {
480 + if ( ! is_array( $options ) ) {
481 + return [];
482 + }
483 + $sanitizable_keys = [ 'label', 'value', 'optionTitle' ];
484 + foreach ( $options as $key => $option ) {
485 + if ( ! is_array( $option ) ) {
486 + continue;
487 + }
488 + foreach ( $sanitizable_keys as $field ) {
489 + if ( isset( $option[ $field ] ) && is_string( $option[ $field ] ) ) {
490 + $options[ $key ][ $field ] = sanitize_text_field( $option[ $field ] );
491 + }
492 + }
493 + }
494 + return $options;
326 495 }
327 496
328 497 }