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 |