| 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 |
|