PluginProbe ʕ •ᴥ•ʔ
SureForms – Drag & Drop Contact Form & Form Builder, Payment Form, Survey, Quiz & Calculator / 2.12.0
SureForms – Drag & Drop Contact Form & Form Builder, Payment Form, Survey, Quiz & Calculator v2.12.0
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 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / admin / html-form-detector.php
sureforms / inc / admin Last commit date
editor-nudge.php 1 month ago html-form-detector.php 1 month ago
html-form-detector.php
1013 lines
1 <?php
2 /**
3 * HTML Form Detector.
4 *
5 * Enqueues the editor-side script that scans `core/html` blocks for raw
6 * `<form>` markup and offers a one-click conversion to a SureForms form.
7 *
8 * This is the free-plugin prototype: detection + UI only. The conversion
9 * callback in the JS layer currently parses locally and logs the result;
10 * the AI-assisted REST endpoint that actually creates the form lives in a
11 * follow-up patch so the detection wiring can be validated independently.
12 *
13 * Script is enqueued when:
14 * - User can manage SureForms forms (same `manage_options` gate as the
15 * rest of the form-admin surface — there is no point offering a CTA to
16 * users who cannot create forms).
17 * - Current screen is the block editor and not the SureForms form CPT
18 * (we never run on the form editor itself — the source `<form>` only
19 * appears on host posts/pages).
20 *
21 * @package sureforms.
22 */
23
24 namespace SRFM\Inc\Admin;
25
26 use SRFM\Inc\Abilities\Forms\Create_Form;
27 use SRFM\Inc\AI_Form_Builder\AI_Helper;
28 use SRFM\Inc\Helper;
29 use SRFM\Inc\Traits\Get_Instance;
30 use WP_Error;
31 use WP_REST_Request;
32 use WP_REST_Server;
33
34 if ( ! defined( 'ABSPATH' ) ) {
35 exit;
36 }
37
38 /**
39 * HTML form detector handler.
40 *
41 * @since 2.10.0
42 */
43 class Html_Form_Detector {
44 use Get_Instance;
45
46 /**
47 * Confidence level below which we route the raw HTML through the AI
48 * middleware instead of trusting the local parser output.
49 *
50 * @since 2.10.0
51 */
52 public const AI_FALLBACK_CONFIDENCE = 'low';
53
54 /**
55 * Hard cap on the size of raw HTML accepted by the conversion endpoint.
56 *
57 * Anything larger is almost certainly the entire page rather than a
58 * single `<form>` and would waste an AI roundtrip on noise. Matches the
59 * upper bound the AI middleware tolerates for `query` payloads.
60 *
61 * @since 2.10.0
62 */
63 public const MAX_HTML_BYTES = 32768;
64
65 /**
66 * Constructor.
67 *
68 * Registers the REST route only on admin / REST-dispatch requests
69 * — that is the only context where the route is reachable, and
70 * gating registration narrows the blast radius if the shared
71 * `Helper::get_items_permissions_check` is ever loosened by an
72 * unrelated change. The endpoint also re-checks `manage_options`
73 * inside the handler so authorization survives both contexts.
74 *
75 * @since 2.10.0
76 */
77 public function __construct() {
78 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
79
80 // Register the REST endpoint unconditionally. The constructor runs on
81 // the `init` hook (see plugin-loader.php), which fires *before*
82 // `parse_request` — the point at which WordPress defines
83 // `REST_REQUEST`. Gating on `REST_REQUEST` here meant the filter was
84 // never attached for the actual REST dispatch, and the endpoint 404'd.
85 // `apply_filters( 'srfm_rest_api_endpoints', ... )` is only invoked
86 // from `Rest_Api::register_endpoints()` on `rest_api_init`, so
87 // attaching this filter on non-REST requests has no runtime cost.
88 add_filter( 'srfm_rest_api_endpoints', [ $this, 'register_rest_endpoint' ] );
89 }
90
91 /**
92 * Decide whether the detector script should be loaded for the current request.
93 *
94 * @since 2.10.0
95 * @return bool
96 */
97 public function allow_load() {
98 if ( ! is_admin() ) {
99 return false;
100 }
101
102 // Gate on the cap required to actually manage SureForms forms — same
103 // rationale as the Editor_Nudge: never surface a "Convert to
104 // SureForms" CTA to a user who cannot reach the form-creation flow.
105 if ( ! Helper::current_user_can( 'manage_options' ) ) {
106 return false;
107 }
108
109 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
110
111 if ( ! $screen || ! method_exists( $screen, 'is_block_editor' ) || ! $screen->is_block_editor() ) {
112 return false;
113 }
114
115 // Skip on the SureForms form editor itself — the source `<form>`
116 // markup we look for only appears on host posts/pages.
117 if ( SRFM_FORMS_POST_TYPE === $screen->post_type ) {
118 return false;
119 }
120
121 return true;
122 }
123
124 /**
125 * Enqueue the detector script when allowed.
126 *
127 * @since 2.10.0
128 * @return void
129 */
130 public function enqueue_scripts() {
131 if ( ! $this->allow_load() ) {
132 return;
133 }
134
135 $handle = SRFM_SLUG . '-html-form-detector';
136 $asset_path = SRFM_DIR . 'assets/build/htmlFormDetector.asset.php';
137 $asset = file_exists( $asset_path )
138 ? include $asset_path
139 : [
140 'dependencies' => [ 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n' ],
141 'version' => SRFM_VER,
142 ];
143
144 wp_enqueue_script(
145 $handle,
146 SRFM_URL . 'assets/build/htmlFormDetector.js',
147 $asset['dependencies'],
148 $asset['version'],
149 true
150 );
151
152 wp_localize_script(
153 $handle,
154 'srfm_html_form_detector',
155 [
156 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
157 ]
158 );
159
160 Helper::register_script_translations( $handle );
161 }
162
163 /**
164 * Register the conversion REST endpoint on the existing SureForms route map.
165 *
166 * Hooked into `srfm_rest_api_endpoints` so we land alongside the other
167 * `sureforms/v1/*` routes without touching `Rest_Api::get_endpoints()` —
168 * keeping every concern of the detector co-located in this class.
169 *
170 * @since 2.10.0
171 * @param array<string,array<string,mixed>> $endpoints Existing endpoints map.
172 * @return array<string,array<string,mixed>>
173 */
174 public function register_rest_endpoint( $endpoints ) {
175 if ( ! is_array( $endpoints ) ) {
176 $endpoints = [];
177 }
178
179 $endpoints['convert-html-form'] = [
180 'methods' => WP_REST_Server::CREATABLE,
181 'callback' => [ $this, 'handle_convert_html_form' ],
182 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
183 'args' => [
184 'parsed_fields' => [
185 'required' => false,
186 'type' => 'array',
187 'description' => __( 'Array of fields produced by the editor-side parser.', 'sureforms' ),
188 ],
189 'submit_text' => [
190 'required' => false,
191 'type' => 'string',
192 'sanitize_callback' => 'sanitize_text_field',
193 'default' => '',
194 ],
195 'confidence' => [
196 'required' => false,
197 'type' => 'string',
198 'sanitize_callback' => 'sanitize_text_field',
199 'default' => 'high',
200 ],
201 'html' => [
202 'required' => false,
203 'type' => 'string',
204 'description' => __( 'Raw HTML of the source <form>. Required when parser confidence is low so we can hand the markup to the AI middleware.', 'sureforms' ),
205 ],
206 'form_title' => [
207 'required' => false,
208 'type' => 'string',
209 'sanitize_callback' => 'sanitize_text_field',
210 'default' => '',
211 ],
212 'styling' => [
213 'required' => false,
214 'type' => 'object',
215 'description' => __( 'Best-effort styling descriptor (hex colors) extracted from inline styles on the source <form>.', 'sureforms' ),
216 ],
217 ],
218 ];
219
220 return $endpoints;
221 }
222
223 /**
224 * Convert a raw HTML form into a SureForms form.
225 *
226 * Flow:
227 * - If the editor-side parser returned `confidence === 'low'` AND raw
228 * HTML is supplied, send the HTML to the AI middleware and use the
229 * structured schema it returns (hybrid path — AI handles markup the
230 * deterministic parser could not confidently classify).
231 * - Otherwise trust the parsed fields and pass them straight to the
232 * existing `Create_Form` ability so the same code that creates AI- /
233 * MCP-generated forms also handles this conversion. Means a single
234 * code path produces the final `sureforms_form` CPT; no parallel
235 * insert logic to maintain.
236 *
237 * @since 2.10.0
238 * @param WP_REST_Request $request REST request.
239 * @return array<string,mixed>|\WP_Error
240 */
241 public function handle_convert_html_form( $request ) {
242 // Capability check runs first — cheaper than `wp_verify_nonce`,
243 // and the REST framework's `permission_callback` already passed
244 // at this point, so a failure here means a `current_user_can`
245 // filter or capability-mapping shim was loosened between the
246 // permission callback and the handler. Bail early to keep
247 // the expensive AI middleware path off the table for users who
248 // could not legitimately complete the action.
249 if ( ! Helper::current_user_can( 'manage_options' ) ) {
250 return new WP_Error(
251 'srfm_html_convert_forbidden',
252 __( 'You are not allowed to convert HTML forms.', 'sureforms' ),
253 [ 'status' => 403 ]
254 );
255 }
256
257 // Nonce check is CSRF defense for the legitimate
258 // `manage_options` user we just confirmed. Running it after the
259 // cap check means cap-less requests never pay the
260 // `hash_hmac`/session-lookup cost of nonce verification, and
261 // the error-code precedence is also more honest: a subscriber
262 // without `manage_options` gets `forbidden`, not the misleading
263 // `nonce_failed`.
264 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
265 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
266 return new WP_Error(
267 'srfm_html_convert_nonce_failed',
268 __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
269 [ 'status' => 403 ]
270 );
271 }
272
273 $raw_fields = $request->get_param( 'parsed_fields' );
274 $confidence = Helper::get_string_value( $request->get_param( 'confidence' ) );
275 $raw_html = Helper::get_string_value( $request->get_param( 'html' ) );
276 $form_title = Helper::get_string_value( $request->get_param( 'form_title' ) );
277 $submit_text = Helper::get_string_value( $request->get_param( 'submit_text' ) );
278 $styling_in = $request->get_param( 'styling' );
279 $styling_in = is_array( $styling_in ) ? $styling_in : [];
280 $used_ai = false;
281
282 if ( '' === $form_title ) {
283 $form_title = __( 'Converted form', 'sureforms' );
284 }
285
286 // AI fallback path. We only invoke the middleware when the local
287 // parser flagged the input as ambiguous AND the caller actually
288 // supplied raw HTML — otherwise there is nothing useful to send.
289 if ( self::AI_FALLBACK_CONFIDENCE === $confidence && '' !== $raw_html ) {
290 if ( strlen( $raw_html ) > self::MAX_HTML_BYTES ) {
291 return new WP_Error(
292 'srfm_html_convert_too_large',
293 __( 'The HTML form is too large to convert. Please simplify the markup or build the form manually.', 'sureforms' ),
294 [ 'status' => 413 ]
295 );
296 }
297
298 $ai_fields = $this->extract_fields_via_ai( $raw_html );
299 if ( is_wp_error( $ai_fields ) ) {
300 return $ai_fields;
301 }
302 $raw_fields = $ai_fields;
303 $used_ai = true;
304 }
305
306 if ( ! is_array( $raw_fields ) || empty( $raw_fields ) ) {
307 return new WP_Error(
308 'srfm_html_convert_no_fields',
309 __( 'No fields could be derived from the supplied form.', 'sureforms' ),
310 [ 'status' => 400 ]
311 );
312 }
313
314 // Strip the parser-internal hints (`_groupName`, `_optionValue`,
315 // `confidence`) before handing fields to Create_Form — the schema
316 // for that ability rejects unknown keys via additionalProperties.
317 $clean_fields = $this->strip_internal_hints( $raw_fields );
318
319 /**
320 * Filter the field list before handing it to `Create_Form`.
321 *
322 * Lets extensions (notably SureForms Pro) re-inspect the raw
323 * source HTML and refine the parsed fields — e.g. promote a
324 * `<input type="date">` from a plain `input` to a `date-picker`
325 * block when the pro field type is registered. The JS parser
326 * cannot do this on its own because the pro field types are
327 * only valid when the pro plugin is active; gating that on the
328 * server is simpler and avoids leaking pro-specific behavior
329 * into the public block-editor bundle.
330 *
331 * @since 2.10.0
332 * @param array<int,array<string,mixed>> $clean_fields Sanitized field list ready for Create_Form.
333 * @param string $raw_html Original HTML of the source `<form>` block.
334 * @param string $confidence Parser confidence (`high`/`medium`/`low`).
335 *
336 * SECURITY CONTRACT: callbacks MUST return values already
337 * sanitized for storage as block attributes. `Create_Form`
338 * re-sanitizes a hardcoded list of properties (label,
339 * placeholder, helpText, defaultValue, fieldOptions), but any
340 * property a callback introduces beyond that set — a pro
341 * field's `allowedFormats`, `dateFormat`, `step`, etc. — is NOT
342 * covered by the downstream sanitization pass. Strings should
343 * pass through `sanitize_text_field` / `wp_kses_post`, scalars
344 * through `absint` / `floatval`, arrays should have each leaf
345 * sanitized. The defensive `strip_unsafe_html_in_fields` pass
346 * below catches obvious raw-tag injection but is not a
347 * substitute for proper per-property sanitization.
348 */
349 $clean_fields = apply_filters( 'srfm_html_form_detector_refine_fields', $clean_fields, $raw_html, $confidence );
350
351 // Defensive post-filter sweep: strip raw HTML tags from every
352 // string leaf in every field property. The filter contract
353 // above documents that callbacks must sanitize, but a sloppy
354 // callback could re-introduce attacker markup in properties
355 // `Create_Form` does not know to clean — at which point a
356 // later block renderer that emits the attribute as inner HTML
357 // becomes a stored-XSS sink. Stripping tags here is a narrow
358 // safety net that loses no legitimate value: form-field
359 // attributes are not HTML containers.
360 $clean_fields = $this->strip_unsafe_html_in_fields( $clean_fields );
361
362 $create_form = new Create_Form();
363 $result = $create_form->execute(
364 [
365 'formTitle' => $form_title,
366 'formFields' => $clean_fields,
367 'formStatus' => 'publish',
368 'formMetaData' => $this->build_form_metadata( $submit_text, $styling_in ),
369 ]
370 );
371
372 if ( is_wp_error( $result ) ) {
373 return $result;
374 }
375
376 // Layer in the native form-card styling (background, padding,
377 // border radius). These live in `_srfm_forms_styling` and are
378 // exposed in the per-form Styling sidebar — the same UI users get
379 // when they build a form by hand — so populating them keeps the
380 // converted form fully editable post-creation instead of locking
381 // the look behind opaque custom CSS.
382 $form_id = isset( $result['form_id'] ) ? Helper::get_integer_value( $result['form_id'] ) : 0;
383 if ( $form_id > 0 ) {
384 $this->apply_native_card_styling( $form_id, $styling_in );
385
386 /**
387 * Fires after the converter writes its baseline form
388 * metadata, giving extensions a chance to layer in
389 * additional `_srfm_forms_styling` keys — e.g. a pro
390 * `form_theme` preset chosen from inline-style hints.
391 *
392 * @since 2.10.0
393 * @param int $form_id Newly-created SureForms form ID.
394 * @param array<string,mixed> $styling Parser styling descriptor (inline-style hints).
395 * @param string $raw_html Original HTML of the source `<form>` block.
396 */
397 do_action( 'srfm_html_form_detector_after_styling', $form_id, $styling_in, $raw_html );
398 }
399
400 // Compute the markup that survives once the source `<form>` is
401 // removed from the original block — wrapping `<div>`s, a
402 // heading above the form, a post-submit paragraph below it,
403 // inline `<script>`, etc. The client also computes this (so
404 // the conversion is responsive even when the response is in
405 // flight), but the client output is treated as untrusted: we
406 // return the server-computed value here and the editor
407 // prefers it when present. This lets us KSES-filter the
408 // remnant for users without `unfiltered_html` so a converter
409 // click cannot surface previously-hidden attacker markup that
410 // the original `<form>` was masking visually.
411 $result['preserved_html'] = $this->strip_form_for_preservation( $raw_html );
412
413 $result['used_ai'] = $used_ai;
414 return $result;
415 }
416
417 /**
418 * Strip the first `<form>` element from the supplied HTML and
419 * return whatever non-empty markup remains, optionally KSES-filtered
420 * when the current user lacks `unfiltered_html`.
421 *
422 * The editor would otherwise drop the source `core/html` block's
423 * non-form content silently when the user clicks Convert. Routing
424 * that decision through the server lets us apply the same
425 * `unfiltered_html` capability gate WordPress applies to every
426 * other path that writes user-supplied HTML into post_content —
427 * site admins on multisite (who have `manage_options` but not
428 * `unfiltered_html`) get the `wp_kses_post` treatment, super
429 * admins on single-site / multisite get the raw markup through.
430 *
431 * @since 2.10.0
432 * @param string $html Original `core/html` block contents.
433 * @return string Stripped (and optionally filtered) markup, or '' when nothing survives.
434 */
435 protected function strip_form_for_preservation( $html ) {
436 if ( ! is_string( $html ) || '' === $html ) {
437 return '';
438 }
439
440 // DOMDocument copes with malformed HTML, wrappers around the
441 // `<form>`, and embedded `<script>`/`<style>` blocks without
442 // us hand-rolling a regex. `libxml_use_internal_errors()` is the
443 // idiomatic way to suppress malformed-HTML warnings without the
444 // `@` operator (mirrors `Helper::strip_js_attributes()`).
445 $dom = new \DOMDocument();
446 libxml_use_internal_errors( true );
447 // Defense-in-depth flags mirror the pro extension's loadHTML
448 // call. `LIBXML_NONET` refuses any outbound DTD fetch even if a
449 // `<!DOCTYPE … SYSTEM …>` ever slipped past `LIBXML_HTML_NODEFDTD`;
450 // the latter suppresses the default HTML4 DTD, which libxml
451 // would otherwise pull in. Single-site admins reach this path
452 // with `unfiltered_html` so the attack surface is mostly
453 // theoretical, but multisite site-admins (manage_options
454 // without unfiltered_html) do not — closing this gives them
455 // the same hardening their pro counterparts get.
456 $loaded = $dom->loadHTML(
457 '<?xml encoding="UTF-8"><div id="srfm-preserve-root">' . $html . '</div>',
458 LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET | LIBXML_HTML_NODEFDTD
459 );
460 libxml_clear_errors();
461 if ( ! $loaded ) {
462 return '';
463 }
464
465 $root = $dom->getElementById( 'srfm-preserve-root' );
466 if ( ! $root instanceof \DOMElement ) {
467 return '';
468 }
469
470 $forms = $root->getElementsByTagName( 'form' );
471 if ( $forms->length > 0 ) {
472 $first = $forms->item( 0 );
473 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM property.
474 if ( $first instanceof \DOMNode && $first->parentNode instanceof \DOMNode ) {
475 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM property.
476 $first->parentNode->removeChild( $first );
477 }
478 }
479
480 $preserved = '';
481 // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOM property.
482 foreach ( iterator_to_array( $root->childNodes ) as $child ) {
483 $preserved .= $dom->saveHTML( $child );
484 }
485
486 $preserved = trim( $preserved );
487 if ( '' === $preserved ) {
488 return '';
489 }
490
491 // Gate `<script>` / `<iframe>` / similar survival on the same
492 // capability WordPress applies to every other unsanitized HTML
493 // sink. On multisite, this means a site admin (who can paste
494 // `<script>` into a `core/html` block only by virtue of
495 // per-site rules WP already enforces) gets the same treatment
496 // here that they would get if the post were saved through the
497 // REST API directly.
498 if ( ! current_user_can( 'unfiltered_html' ) ) {
499 $preserved = wp_kses_post( $preserved );
500 }
501
502 return $preserved;
503 }
504
505 /**
506 * Merge background / padding / border-radius into the form's
507 * `_srfm_forms_styling` meta — the same array the Styling sidebar
508 * writes to in the form editor.
509 *
510 * Why this is a post-create step instead of going through the
511 * Form_Metadata trait: the trait only exposes the colors +
512 * field-spacing slice of the styling array (primary, text, text on
513 * primary, field_spacing). Padding, border radius, and the
514 * embedded-form background (`bg_type` / `bg_color`) are not in its
515 * input schema. Rather than expand the shared trait — which is also
516 * used by the MCP `update-form` ability and would broaden the
517 * blast radius of any schema mistake — we write the extra keys
518 * directly here, keeping the change scoped to the conversion flow.
519 *
520 * All keys touched (`bg_type`, `bg_color`, `form_padding_*`,
521 * `form_border_radius_*`) exist in the FREE plugin (see
522 * `Form_Styling::map_block_attrs_to_styling` and the Styling tab in
523 * `src/admin/single-form-settings/tabs/StyleSettings.js` — neither
524 * gates these behind `SRFM_PRO_VER`). Pro is not required.
525 *
526 * @since 2.10.0
527 * @param int $form_id Newly-created form ID.
528 * @param array<string,mixed> $styling Parser styling descriptor.
529 * @return void
530 */
531 protected function apply_native_card_styling( $form_id, $styling ) {
532 $existing = get_post_meta( $form_id, '_srfm_forms_styling', true );
533 $existing = is_array( $existing ) ? $existing : [];
534
535 $updates = [];
536
537 if ( ! empty( $styling['formBackgroundColor'] ) ) {
538 $hex = sanitize_hex_color( Helper::get_string_value( $styling['formBackgroundColor'] ) );
539 if ( $hex ) {
540 // `bg_type` must accompany `bg_color` — `Generate_Form_Markup`
541 // only emits `--srfm-bg-color` when `bg_type === 'color'`,
542 // so setting the color without the type is silently dropped.
543 $updates['bg_type'] = 'color';
544 $updates['bg_color'] = $hex;
545 }
546 }
547
548 $padding = $this->shorthand_to_sides( $styling['formPadding'] ?? '' );
549 if ( null !== $padding ) {
550 $updates['form_padding_top'] = $padding['top'];
551 $updates['form_padding_right'] = $padding['right'];
552 $updates['form_padding_bottom'] = $padding['bottom'];
553 $updates['form_padding_left'] = $padding['left'];
554 $updates['form_padding_unit'] = $padding['unit'];
555 $updates['form_padding_link'] = $padding['link'];
556 }
557
558 $radius = $this->shorthand_to_sides( $styling['formBorderRadius'] ?? '' );
559 if ( null !== $radius ) {
560 $updates['form_border_radius_top'] = $radius['top'];
561 $updates['form_border_radius_right'] = $radius['right'];
562 $updates['form_border_radius_bottom'] = $radius['bottom'];
563 $updates['form_border_radius_left'] = $radius['left'];
564 $updates['form_border_radius_unit'] = $radius['unit'];
565 $updates['form_border_radius_link'] = $radius['link'];
566 }
567
568 if ( empty( $updates ) ) {
569 return;
570 }
571
572 update_post_meta( $form_id, '_srfm_forms_styling', array_merge( $existing, $updates ) );
573 }
574
575 /**
576 * Parse a CSS box-model shorthand string (e.g. `24px`, `12px 16px`,
577 * `1rem 2rem 1rem 2rem`) into the 4-side structure that
578 * `_srfm_forms_styling` expects.
579 *
580 * Returns `null` when the value is unusable so callers can skip the
581 * meta write entirely — better than writing zeros that would
582 * silently override defaults set elsewhere. Only px / rem / em / %
583 * units are accepted; anything else falls back to px to match the
584 * SureForms admin's allowed values.
585 *
586 * Security-critical input boundary — DO NOT relax without auditing
587 * `Generate_Form_Markup`: values from this function flow verbatim
588 * into the form-container `<style>` block. The strict numeric +
589 * whitelisted-unit regex below is what prevents a crafted source
590 * `style="padding: }</style><script>…"` from escaping the
591 * declaration and producing stored XSS. If you ever extend the
592 * accepted units (e.g. `calc()`, `vh`, `vw`) you MUST also confirm
593 * the downstream consumer still wraps the value in a context where
594 * those tokens cannot escape into the surrounding markup.
595 *
596 * @since 2.10.0
597 * @param mixed $value Raw shorthand string from the parser.
598 * @return array{top:float,right:float,bottom:float,left:float,unit:string,link:bool}|null
599 */
600 protected function shorthand_to_sides( $value ) {
601 if ( ! is_string( $value ) ) {
602 return null;
603 }
604 $value = trim( $value );
605 if ( '' === $value ) {
606 return null;
607 }
608
609 $parts = preg_split( '/\s+/', $value );
610 if ( ! is_array( $parts ) || empty( $parts ) ) {
611 return null;
612 }
613
614 $nums = [];
615 $units = [];
616 foreach ( $parts as $part ) {
617 // Security-critical regex — see method docblock. The unit
618 // alternation MUST stay a fixed whitelist.
619 if ( ! preg_match( '/^(-?\d+(?:\.\d+)?)(px|rem|em|%)?$/', $part, $m ) ) {
620 return null;
621 }
622 $nums[] = (float) $m[1];
623 $units[] = $m[2] ?? 'px';
624 }
625
626 // Normalize the unit: SureForms admin stores ONE unit shared by
627 // all four sides. When the source mixed units (rare) we keep the
628 // first one — the alternative of converting between units would
629 // be lossy and surprising.
630 $unit = in_array( $units[0], [ 'px', 'rem', 'em', '%' ], true ) ? $units[0] : 'px';
631
632 // Expand CSS shorthand semantics: 1 → all sides, 2 → vert/horiz,
633 // 3 → top, horiz, bottom, 4 → top, right, bottom, left.
634 switch ( count( $nums ) ) {
635 case 1:
636 $sides = [ $nums[0], $nums[0], $nums[0], $nums[0] ];
637 break;
638 case 2:
639 $sides = [ $nums[0], $nums[1], $nums[0], $nums[1] ];
640 break;
641 case 3:
642 $sides = [ $nums[0], $nums[1], $nums[2], $nums[1] ];
643 break;
644 case 4:
645 $sides = [ $nums[0], $nums[1], $nums[2], $nums[3] ];
646 break;
647 default:
648 return null;
649 }
650
651 return [
652 'top' => $sides[0],
653 'right' => $sides[1],
654 'bottom' => $sides[2],
655 'left' => $sides[3],
656 'unit' => $unit,
657 // `link` is the admin's "all sides linked" toggle — when the
658 // shorthand collapsed to one value, the user clearly meant
659 // every side to match, so link them.
660 'link' => 1 === count( $nums ),
661 ];
662 }
663
664 /**
665 * Send raw HTML to the AI middleware and return the structured field list.
666 *
667 * The middleware was originally designed for natural-language prompts
668 * ("a contact form with name, email, message"), but its system prompt
669 * always produces `form.formFields` — feeding the HTML as the prompt
670 * with an explicit instruction reliably yields a usable schema. If the
671 * middleware errors or returns a malformed payload we surface a single
672 * WP_Error so the caller can fall back to a manual create-form CTA.
673 *
674 * Privacy hardening: the source `<form>` markup may contain values
675 * the admin did not author — prefilled hidden inputs from a previous
676 * form library, CSRF tokens, server-rendered email addresses, etc.
677 * We strip `<input type="hidden">` and `value="..."` attributes
678 * before forwarding so the middleware only sees the structural
679 * shape (which is all it needs to infer field types). The endpoint
680 * is admin-trusted, so this is defense-in-depth rather than a
681 * trust-boundary check, but it meaningfully reduces what leaves
682 * the site.
683 *
684 * Defensive filtering on the response: even though the middleware
685 * is trusted, we drop any non-array entries from `formFields`
686 * before returning so a malformed payload cannot reach the
687 * `Create_Form` schema validator as a surprise scalar.
688 *
689 * @since 2.10.0
690 * @param string $html Raw HTML containing the source `<form>`.
691 * @return array<int,array<string,mixed>>|\WP_Error
692 */
693 protected function extract_fields_via_ai( $html ) {
694 $sanitized_html = $this->scrub_html_for_ai( $html );
695
696 // English-only by design — this is the instruction sent to the
697 // AI middleware, not user-facing copy. Wrapping it in `__()`
698 // would invite translators to localize a machine prompt (and
699 // the model is not multilingual on the conversion task today).
700 $query = sprintf(
701 'Convert the following raw HTML form into a SureForms field schema. Preserve field types, labels, the required attribute, and any select/radio/checkbox options. Do not invent fields that are not present in the markup. HTML: %s',
702 $sanitized_html
703 );
704
705 $response = AI_Helper::get_chat_completions_response( [ 'query' => $query ] );
706
707 if ( ! is_array( $response ) || ! empty( $response['error'] ) ) {
708 return new WP_Error(
709 'srfm_html_convert_ai_failed',
710 __( 'The SureForms AI service could not process this form. Try again or build the form manually.', 'sureforms' ),
711 [ 'status' => 502 ]
712 );
713 }
714
715 if (
716 empty( $response['form'] ) ||
717 ! is_array( $response['form'] ) ||
718 empty( $response['form']['formFields'] ) ||
719 ! is_array( $response['form']['formFields'] )
720 ) {
721 return new WP_Error(
722 'srfm_html_convert_ai_empty',
723 __( 'The SureForms AI service returned an unusable response. Try again or build the form manually.', 'sureforms' ),
724 [ 'status' => 502 ]
725 );
726 }
727
728 // Belt-and-braces: trust the middleware to return well-formed
729 // fields but never let a non-array slip through to the
730 // downstream schema validator.
731 $fields = array_values( array_filter( $response['form']['formFields'], 'is_array' ) );
732
733 if ( empty( $fields ) ) {
734 return new WP_Error(
735 'srfm_html_convert_ai_empty',
736 __( 'The SureForms AI service returned an unusable response. Try again or build the form manually.', 'sureforms' ),
737 [ 'status' => 502 ]
738 );
739 }
740
741 return $fields;
742 }
743
744 /**
745 * Remove pre-filled values + hidden inputs from the source HTML
746 * before handing it to the AI middleware. The structural shape of
747 * a form (`<input type="email" name="..." required>`) is enough
748 * for the model to infer the correct SureForms field type; the
749 * concrete `value="..."` payload, hidden CSRF tokens, and
750 * action-URL attributes only widen the data we send out.
751 *
752 * Implementation note: we deliberately do this as a regex pass
753 * rather than round-tripping through `DOMDocument` to avoid an
754 * encoding renormalization step on the JS-supplied bytes (each
755 * `loadHTML` / `saveHTML` pair can mutate whitespace and entity
756 * encoding in ways the AI prompt is sensitive to).
757 *
758 * @since 2.10.0
759 * @param string $html Source HTML markup.
760 * @return string Sanitized markup safe to forward.
761 */
762 protected function scrub_html_for_ai( $html ) {
763 // Normalize Unicode whitespace separators to plain ASCII space
764 // before the regex pass. Without this, a payload such as
765 // `<input type=<NBSP>"hidden">` (U+00A0 between attr name and
766 // the `=`) sails through every `\s`-based pattern below because
767 // PCRE's default `\s` is ASCII-only. Defense-in-depth — the
768 // trust boundary is the `manage_options` cap on the route — but
769 // the cost is one `str_replace` and the regex patterns stay
770 // readable.
771 $html = str_replace( [ "\xC2\xA0", "\xE2\x80\x83", "\xE2\x80\x82" ], ' ', $html );
772
773 // Drop hidden inputs entirely.
774 $html = preg_replace(
775 '/<input\b[^>]*\btype\s*=\s*["\']?hidden["\']?[^>]*>/i',
776 '',
777 $html
778 );
779 if ( ! is_string( $html ) ) {
780 return '';
781 }
782
783 // Strip any remaining `value="..."` / `value='...'` so
784 // pre-filled defaults do not leave the site. Attribute names
785 // without a value (boolean attrs like `required`) are
786 // untouched.
787 $html = preg_replace(
788 '/\svalue\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i',
789 '',
790 $html
791 );
792 if ( ! is_string( $html ) ) {
793 return '';
794 }
795
796 // Strip the form's `action` attribute — the middleware does
797 // not need the host site's internal endpoint URL.
798 $html = preg_replace(
799 '/(<form\b[^>]*?)\saction\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i',
800 '$1',
801 $html
802 );
803 if ( ! is_string( $html ) ) {
804 return '';
805 }
806
807 // Strip HTML comments. Source authors sometimes leave server-
808 // side staging notes, build hashes, or even API keys inside
809 // `<!-- ... -->`. The model has no use for them and the regex
810 // scrubber's whole purpose is to keep that surface out of the
811 // outbound request.
812 $html = preg_replace( '/<!--.*?-->/s', '', $html );
813 if ( ! is_string( $html ) ) {
814 return '';
815 }
816
817 // Empty out `<script>` bodies — the structural fact that a
818 // `<script>` block exists may be useful for the model to
819 // recognize multi-step / handler-driven forms, but the
820 // contents (often containing tokens, endpoint URLs, or
821 // inline configuration) must not leave the site. Keep the
822 // opening/closing tags so the model still sees the shape.
823 $html = preg_replace(
824 '/(<script\b[^>]*>).*?(<\/script>)/is',
825 '$1$2',
826 $html
827 );
828 if ( ! is_string( $html ) ) {
829 return '';
830 }
831
832 // Empty out `<textarea>` bodies for the same reason as the
833 // hidden-input scrub above: pre-filled defaults can be CSRF
834 // tokens, server-rendered email addresses, or the user's
835 // stored draft. The model only needs to know a textarea is
836 // present at this position; the existing content adds nothing
837 // to the conversion and is exactly the data class we are
838 // trying not to forward.
839 $html = preg_replace(
840 '/(<textarea\b[^>]*>).*?(<\/textarea>)/is',
841 '$1$2',
842 $html
843 );
844
845 return is_string( $html ) ? $html : '';
846 }
847
848 /**
849 * Build the `formMetaData` payload for the Create_Form ability from the
850 * conversion inputs.
851 *
852 * Why every conversion sets an explicit `textColor`:
853 * `Generate_Form_Markup` emits the CSS variables that drive input
854 * borders (`--srfm-color-input-border`, `--srfm-color-input-background`,
855 * etc.) as `hsl( from <textColor> ... )`. Unlike `primaryColor`, the
856 * upstream code does not substitute a fallback when `textColor` is
857 * empty — the generated CSS becomes `hsl( from h s l / 0.25 )` which
858 * is invalid, so the browser drops the rule and every input renders
859 * with `border: 0`. The user-visible symptom is "the form looks
860 * invisible after conversion". We sidestep the bug by always passing a
861 * non-empty `text_color` for converted forms, falling back to a neutral
862 * near-black when the source form had no inline color we could read.
863 *
864 * Why `showTitle` is forced to false:
865 * The converted form is embedded on a host page via shortcode, where
866 * the user typically already has a heading above the embed. Letting
867 * SureForms render its own form title there produces the duplicate
868 * "Contact Us" header we saw in QA.
869 *
870 * @since 2.10.0
871 * @param string $submit_text Submit button label extracted from the source form.
872 * @param array<string,mixed> $styling Inline-style colors the parser was able to read.
873 * @return array<string,mixed>
874 */
875 protected function build_form_metadata( $submit_text, $styling ) {
876 $form_styling = [
877 // Always non-empty — see method docblock for why we cannot let
878 // this fall through to the upstream empty-string default.
879 'textColor' => $this->pick_hex( $styling['textColor'] ?? null, '#1E1E1E' ),
880 'fieldSpacing' => 'medium',
881 ];
882
883 // Pass through any inline colors the parser surfaced. We
884 // intentionally do NOT inject opinionated defaults for these —
885 // SureForms' own defaults (`#046bd2` primary, `#111827` text on
886 // primary) are exactly what users get when they create a form via
887 // the admin UI, so omitting these keys keeps converted forms
888 // visually consistent with hand-built ones.
889 if ( ! empty( $styling['primaryColor'] ) ) {
890 $hex = sanitize_hex_color( Helper::get_string_value( $styling['primaryColor'] ) );
891 if ( $hex ) {
892 $form_styling['primaryColor'] = $hex;
893 }
894 }
895 if ( ! empty( $styling['textColorOnPrimary'] ) ) {
896 $hex = sanitize_hex_color( Helper::get_string_value( $styling['textColorOnPrimary'] ) );
897 if ( $hex ) {
898 $form_styling['textColorOnPrimary'] = $hex;
899 }
900 }
901
902 $meta = [
903 'formStyling' => $form_styling,
904 'instantForm' => [
905 // See method docblock for why this is unconditional.
906 'showTitle' => false,
907 ],
908 ];
909
910 if ( '' !== $submit_text ) {
911 $meta['general'] = [ 'submitText' => $submit_text ];
912 }
913
914 // Form background / padding / border-radius are NOT applied through
915 // `formMetaData` here — the `Form_Metadata` trait only exposes the
916 // colors + field-spacing slice of styling. The full card-look
917 // settings are written directly to `_srfm_forms_styling` via
918 // `apply_native_card_styling()` after `Create_Form` runs. See the
919 // docblock there for why we bypass the trait.
920
921 return $meta;
922 }
923
924 /**
925 * Return the first sanitized hex color from a candidate value, falling
926 * back to a default. Centralizes the `sanitize_hex_color() || default`
927 * pattern used in several places in `build_form_metadata()`.
928 *
929 * @since 2.10.0
930 * @param mixed $value Candidate hex color.
931 * @param string $default Default to use when the candidate is unusable.
932 * @return string
933 */
934 protected function pick_hex( $value, $default ) {
935 if ( ! is_string( $value ) || '' === $value ) {
936 return $default;
937 }
938 $sanitized = sanitize_hex_color( $value );
939 return $sanitized ? $sanitized : $default;
940 }
941
942 /**
943 * Remove keys that exist only to ferry parser context across the HTTP
944 * boundary. `Create_Form::get_input_schema()` sets
945 * `additionalProperties: false`, so passing through `_groupName`,
946 * `_optionValue`, or `confidence` would reject the entire request.
947 *
948 * @since 2.10.0
949 * @param array<int,mixed> $fields Raw parser fields.
950 * @return array<int,array<string,mixed>>
951 */
952 protected function strip_internal_hints( $fields ) {
953 $internal = [ '_groupName', '_optionValue', '_groupLabel', 'confidence' ];
954 $cleaned = [];
955
956 foreach ( $fields as $field ) {
957 if ( ! is_array( $field ) ) {
958 continue;
959 }
960 foreach ( $internal as $key ) {
961 unset( $field[ $key ] );
962 }
963 $cleaned[] = $field;
964 }
965
966 return $cleaned;
967 }
968
969 /**
970 * Defensive sweep over post-filter fields. Walks every string leaf
971 * inside every field — including arbitrary-depth `fieldOptions`
972 * arrays — and runs each one through `wp_strip_all_tags` so a
973 * sloppy `srfm_html_form_detector_refine_fields` callback cannot
974 * smuggle raw HTML into a downstream block attribute.
975 *
976 * Form-field attributes are not HTML containers — labels,
977 * placeholders, helptext, and option labels render as text-nodes
978 * (escaped via the block renderer's `esc_html`) or as attribute
979 * values (escaped via `esc_attr`). Stripping tags here loses no
980 * legitimate value and short-circuits the stored-XSS path for
981 * any pro-added property that `Create_Form`'s hardcoded sanitize
982 * list does not cover.
983 *
984 * @since 2.10.0
985 * @param array<int,array<string,mixed>> $fields Filter output.
986 * @return array<int,array<string,mixed>>
987 */
988 protected function strip_unsafe_html_in_fields( $fields ) {
989 if ( ! is_array( $fields ) ) {
990 return [];
991 }
992
993 $walker = static function ( $value ) use ( &$walker ) {
994 if ( is_string( $value ) ) {
995 return wp_strip_all_tags( $value );
996 }
997 if ( is_array( $value ) ) {
998 return array_map( $walker, $value );
999 }
1000 return $value;
1001 };
1002
1003 $cleaned = [];
1004 foreach ( $fields as $field ) {
1005 if ( ! is_array( $field ) ) {
1006 continue;
1007 }
1008 $cleaned[] = array_map( $walker, $field );
1009 }
1010 return $cleaned;
1011 }
1012 }
1013