PluginProbe
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.5.3
JetFormBuilder — Dynamic Blocks Form Builder v3.6.5.3
3.6.5.3 3.6.5.2 3.6.5.1 3.6.5 3.6.4.2 3.6.4.1 3.6.4 3.6.3.1 3.6.3 3.6.2.2 3.6.2.1 3.6.2 3.6.1.1 3.6.1 3.6.0.1 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 131 releases
jetformbuilder / modules / validation / module.php

module.php in JetFormBuilder — Dynamic Blocks Form Builder 3.6.5.3, at modules/validation/module.php

799 lines 25.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 namespace JFB_Modules\Validation;
5
6 use Jet_Form_Builder\Admin\Editor;
7 use Jet_Form_Builder\Blocks\Types\Base;
8 use Jet_Form_Builder\Classes\Arrayable\Array_Tools;
9 use Jet_Form_Builder\Classes\Tools;
10 use Jet_Form_Builder\Exceptions\Repository_Exception;
11 use Jet_Form_Builder\Plugin;
12 use JFB_Components\Module\Base_Module_After_Install_It;
13 use JFB_Components\Module\Base_Module_Dir_It;
14 use JFB_Components\Module\Base_Module_Dir_Trait;
15 use JFB_Components\Module\Base_Module_Handle_It;
16 use JFB_Components\Module\Base_Module_Handle_Trait;
17 use JFB_Components\Module\Base_Module_It;
18 use JFB_Components\Module\Base_Module_Url_It;
19 use JFB_Components\Module\Base_Module_Url_Trait;
20 use Jet_Form_Builder\Admin\Pages\Pages_Manager;
21 use Jet_Form_Builder\Admin\Tabs_Handlers\Ssr_Callbacks_Handler;
22 use Jet_Form_Builder\Admin\Tabs_Handlers\Tab_Handler_Manager;
23 use JFB_Modules\Block_Parsers\Field_Data_Parser;
24 use JFB_Modules\Validation\Advanced_Rules\Server_Side_Rule;
25 use JFB_Modules\Validation\Advanced_Rules\Ssr_Callback_Registry;
26 use JFB_Modules\Validation\Class_Validation_Handlers;
27 use JFB_Modules\Validation\Handlers\Validation_Handler;
28 use JFB_Modules\Validation\Rest_Api\Rest_Validation_Endpoint;
29 use JFB_Modules\Validation\Ssr\Ssr_Blocked_Callback_Usages;
30 use JFB_Modules\Validation\Ssr\Ssr_Registry_Migration_Notice;
31
32 // If this file is called directly, abort.
33 if ( ! defined( 'WPINC' ) ) {
34 die;
35 }
36
37 final class Module implements
38 Base_Module_It,
39 Base_Module_Handle_It,
40 Base_Module_Url_It,
41 Base_Module_Dir_It,
42 Base_Module_After_Install_It {
43
44 use Base_Module_Url_Trait;
45 use Base_Module_Handle_Trait;
46 use Base_Module_Dir_Trait;
47
48 const FORMAT_ADVANCED = 'advanced';
49 const FORMAT_BROWSER = 'browser';
50 const HANDLE = 'jet-fb-advanced-reporting';
51
52 private $messages = array();
53 /**
54 * @var Rules_Controller
55 */
56 private $rules;
57 private $settings;
58 private $inline_messages = array();
59 /**
60 * @var Ssr_Registry_Migration_Notice
61 */
62 private $ssr_registry_migration_notice;
63
64 public function rep_item_id() {
65 return 'validation';
66 }
67
68 public function condition(): bool {
69 return true;
70 }
71
72 /**
73 * @throws Repository_Exception
74 */
75 public function on_install() {
76 $handlers = new Class_Validation_Handlers();
77
78 foreach ( $handlers->get_handlers() as $handler ) {
79 if ( method_exists( $handler, 'init' ) ) {
80 $handler->init();
81 }
82 }
83
84 /** @var \JFB_Modules\Post_Type\Module $post_type */
85 $post_type = jet_form_builder()->module( 'post-type' );
86 $post_type->get_meta()->install( new Post_Type\Validation_Meta() );
87
88 /** @var \JFB_Modules\Rest_Api\Module $rest_api */
89 $rest_api = jet_form_builder()->module( 'rest-api' );
90 $rest_api->get_controller()->install( new Rest_Api\Rest_Validation_Endpoint() );
91
92 Tab_Handler_Manager::instance()->install( new Ssr_Callbacks_Handler() );
93 }
94
95 /**
96 * @throws Repository_Exception
97 */
98 public function on_uninstall() {
99 $this->rules = null;
100 $this->messages = array();
101
102 /** @var \JFB_Modules\Post_Type\Module $post_type */
103 $post_type = jet_form_builder()->module( 'post-type' );
104 $post_type->get_meta()->uninstall( Post_Type\Validation_Meta::class );
105
106 /** @var \JFB_Modules\Rest_Api\Module $rest_api */
107 $rest_api = jet_form_builder()->module( 'rest-api' );
108 $rest_api->get_controller()->uninstall( new Rest_Api\Rest_Validation_Endpoint() );
109
110 Tab_Handler_Manager::instance()->uninstall( 'ssr-callbacks-tab' );
111 }
112
113 public function init_hooks() {
114 add_filter(
115 'jet-form-builder/before-start-form',
116 array( $this, 'add_validation_messages_global' )
117 );
118
119 add_action(
120 'jet-form-builder/before-start-form-row',
121 array( $this, 'add_validation_block' )
122 );
123 add_action(
124 'wp_enqueue_scripts',
125 array( $this, 'register_scripts' )
126 );
127
128 /**
129 * @link https://github.com/Crocoblock/issues-tracker/issues/1542
130 */
131 add_action(
132 'jet_plugins/frontend/register_scripts',
133 array( $this, 'register_scripts' )
134 );
135 add_action(
136 'jet-form-builder/validate-field',
137 array( $this, 'validate_block' ),
138 0
139 );
140 add_action(
141 'jet-form-builder/editor-assets/before',
142 array( $this, 'localize_editor_config' )
143 );
144 add_action(
145 'save_post_jet-form-builder',
146 array( $this, 'refresh_blocked_callback_usages' )
147 );
148 add_action(
149 'delete_post',
150 array( $this, 'remove_blocked_callback_usages_for_deleted_form' )
151 );
152 add_action(
153 'save_post_wp_block',
154 array( $this, 'refresh_blocked_callback_usages_for_referencing_forms' )
155 );
156 add_action(
157 'deleted_post',
158 array( $this, 'refresh_blocked_callback_usages_for_referencing_forms_on_delete' )
159 );
160
161 $this->ssr_registry_migration_notice = new Ssr_Registry_Migration_Notice();
162 $this->ssr_registry_migration_notice->init_hooks();
163 }
164
165 public function remove_hooks() {
166 remove_filter(
167 'jet-form-builder/before-start-form',
168 array( $this, 'add_validation_messages_global' )
169 );
170 remove_action(
171 'jet-form-builder/before-start-form-row',
172 array( $this, 'add_validation_block' )
173 );
174 remove_action(
175 'wp_enqueue_scripts',
176 array( $this, 'register_scripts' )
177 );
178
179 /**
180 * @link https://github.com/Crocoblock/issues-tracker/issues/1542
181 */
182 remove_action(
183 'jet_plugins/frontend/register_scripts',
184 array( $this, 'register_scripts' )
185 );
186 remove_action(
187 'jet-form-builder/validate-field',
188 array( $this, 'validate_block' ),
189 0
190 );
191 remove_action(
192 'jet-form-builder/editor-assets/before',
193 array( $this, 'localize_editor_config' )
194 );
195 remove_action(
196 'save_post_jet-form-builder',
197 array( $this, 'refresh_blocked_callback_usages' )
198 );
199 remove_action(
200 'delete_post',
201 array( $this, 'remove_blocked_callback_usages_for_deleted_form' )
202 );
203 remove_action(
204 'save_post_wp_block',
205 array( $this, 'refresh_blocked_callback_usages_for_referencing_forms' )
206 );
207 remove_action(
208 'deleted_post',
209 array( $this, 'refresh_blocked_callback_usages_for_referencing_forms_on_delete' )
210 );
211
212 if ( $this->ssr_registry_migration_notice ) {
213 $this->ssr_registry_migration_notice->remove_hooks();
214 }
215 }
216
217 public function register_scripts() {
218 $script_asset = require_once jet_form_builder()->plugin_dir(
219 'assets/build/frontend/advanced.reporting.asset.php'
220 );
221
222 if ( true === $script_asset ) {
223 return;
224 }
225
226 array_push(
227 $script_asset['dependencies'],
228 \Jet_Form_Builder\Blocks\Module::MAIN_SCRIPT_HANDLE
229 );
230
231 wp_register_script(
232 self::HANDLE,
233 jet_form_builder()->plugin_url( 'assets/build/frontend/advanced.reporting.js' ),
234 $script_asset['dependencies'],
235 $script_asset['version'],
236 true
237 );
238 }
239
240 public function add_validation_messages_global( string $markup, bool $force = false ): string {
241 $this->settings = $this->get_settings();
242 $form_id = jet_fb_live()->form_id;
243
244 if (
245 ( ! $this->is_advanced_form() && ! $force ) ||
246 in_array( $form_id, $this->inline_messages, true )
247 ) {
248 return $markup;
249 }
250
251 $data = Tools::encode_json( $this->settings );
252
253 wp_enqueue_script( self::HANDLE );
254
255 wp_add_inline_script(
256 \Jet_Form_Builder\Blocks\Module::MAIN_SCRIPT_HANDLE,
257 "
258 window.JetFormsValidation = window.JetFormsValidation ?? {};
259 window.JetFormsValidation[ {$form_id} ] = $data;
260 "
261 );
262
263 add_action(
264 'wp_enqueue_scripts',
265 function () use ( $form_id, $data ) {
266 wp_add_inline_script(
267 \Jet_Form_Builder\Blocks\Module::MAIN_SCRIPT_HANDLE,
268 "
269 window.JetFormsValidation = window.JetFormsValidation ?? {};
270 window.JetFormsValidation[ {$form_id} ] = $data;
271 "
272 );
273 },
274 20
275 );
276
277 $this->inline_messages[] = $form_id;
278
279 return $markup;
280 }
281
282 /**
283 * Keeps `Ssr_Blocked_Callback_Usages` in sync with a form's current content: an admin
284 * who edits a form to fix a denylisted "Server-Side callback" rule (issues-tracker
285 * #20361 follow-up) should see it drop off the "Forms Using Blocked Functions" list
286 * immediately, not have it linger as if nothing changed. Only ever removes/replaces
287 * entries for the saved form's own ID — it never grants trust to anything (a denylisted
288 * name can never be approved regardless of what this records), so it carries none of the
289 * self-service allowlist risk the retired `Ssr_Callback_Allowlist` save-post hooks had.
290 *
291 * @since 3.6.5.3
292 *
293 * @param int $post_id
294 */
295 public function refresh_blocked_callback_usages( int $post_id ) {
296 if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
297 return;
298 }
299
300 $post = get_post( $post_id );
301
302 if ( ! $post instanceof \WP_Post ) {
303 return;
304 }
305
306 Ssr_Blocked_Callback_Usages::replace_for_form(
307 $post_id,
308 Ssr_Blocked_Callback_Usages::collect_from_content( $post->post_content )
309 );
310 }
311
312 /**
313 * `refresh_blocked_callback_usages()` only ever runs on `save_post_jet-form-builder`, so
314 * a form that is permanently deleted (not just trashed — `Ssr_Blocked_Callback_Usages`
315 * should still list a trashed form, since it can still be restored) never gets its entry
316 * removed: the settings tab would otherwise keep showing a phantom "Forms Using Blocked
317 * Functions" row with a dead edit link forever (review finding, issues-tracker #20361
318 * follow-up). `delete_post` fires for every post type, so this checks `post_type` itself
319 * rather than relying on a `delete_post_{post_type}`-style hook, which WordPress does not
320 * provide.
321 *
322 * @since 3.6.5.3
323 *
324 * @param int $post_id
325 */
326 public function remove_blocked_callback_usages_for_deleted_form( int $post_id ) {
327 if ( 'jet-form-builder' !== get_post_type( $post_id ) ) {
328 return;
329 }
330
331 Ssr_Blocked_Callback_Usages::clear_for_form( $post_id );
332 }
333
334 /**
335 * `Ssr_Blocked_Callback_Usages::collect_from_content()` expands a form's `core/block`
336 * (reusable block) references to the reusable block's *current* content every time it
337 * runs — but `refresh_blocked_callback_usages()` only runs on `save_post_jet-form-
338 * builder`. Editing a reusable block to add or remove a blocked "Server-Side callback"
339 * rule therefore left every form referencing it stale indefinitely: an admin removing
340 * the blocked rule from a shared reusable block would not see the referencing forms drop
341 * off "Forms Using Blocked Functions" (and, conversely, adding one would not surface a
342 * newly-broken form) until each was individually resaved (review finding, issues-tracker
343 * #20361 follow-up). Hooked to `save_post_wp_block`; re-runs `refresh_blocked_callback_
344 * usages()` for every `jet-form-builder` form whose `post_content` references this
345 * reusable block's ID, so its current state is reflected without waiting on those forms
346 * to be resaved themselves.
347 *
348 * @since 3.6.5.3
349 *
350 * @param int $reusable_block_id
351 */
352 public function refresh_blocked_callback_usages_for_referencing_forms( int $reusable_block_id ) {
353 if ( wp_is_post_autosave( $reusable_block_id ) || wp_is_post_revision( $reusable_block_id ) ) {
354 return;
355 }
356
357 foreach ( $this->find_forms_referencing_reusable_block( $reusable_block_id ) as $form_id ) {
358 $this->refresh_blocked_callback_usages( $form_id );
359 }
360 }
361
362 /**
363 * Same gap as `refresh_blocked_callback_usages_for_referencing_forms()`, for the other
364 * way a reusable block's contribution can change: being permanently deleted.
365 *
366 * Hooked to `deleted_post` (fires immediately *after* the row is actually removed from
367 * `wp_posts`), not `delete_post` (fires while the row still exists, before the DB
368 * `DELETE`): re-running the refresh on `delete_post` would still resolve the reusable
369 * block being deleted via `get_post()` to its old, still-present content, writing back
370 * the exact same blocked usages that are about to become stale — and since nothing else
371 * refreshes after the row is actually gone, the referencing forms would then be stuck
372 * listing usages from a reusable block that no longer exists, indefinitely (review
373 * finding, issues-tracker #20361 follow-up).
374 *
375 * `get_post_type( $post_id )` cannot be used to filter by type here the way
376 * `remove_blocked_callback_usages_for_deleted_form()` does on `delete_post` above — by
377 * `deleted_post` the row (and its cache entry) is already gone, so that lookup would
378 * always return `false`. `deleted_post` passes the now-deleted `WP_Post` object itself as
379 * its second argument, which is the only remaining way to recover its `post_type`.
380 *
381 * @since 3.6.5.3
382 *
383 * @param int $post_id
384 * @param \WP_Post|null $post
385 */
386 public function refresh_blocked_callback_usages_for_referencing_forms_on_delete( int $post_id, $post = null ) {
387 if ( ! $post instanceof \WP_Post || 'wp_block' !== $post->post_type ) {
388 return;
389 }
390
391 $this->refresh_blocked_callback_usages_for_referencing_forms( $post_id );
392 }
393
394 /**
395 * Finds every `jet-form-builder` form whose `post_content` references the given reusable
396 * block ID, directly or through a chain of nested reusable blocks (form → reusable block
397 * A → reusable block B, where B is `$reusable_block_id`).
398 *
399 * A single `LIKE '%"ref":<id>%'` scan of *forms* only would miss that chain: B's ref
400 * never appears in the form's own `post_content` at all — only inside A's, which is a
401 * separate `wp_block` post the form's content has no reason to mention (review finding,
402 * issues-tracker #20361 follow-up). This therefore first transitively finds every
403 * `wp_block` post that references `$reusable_block_id`, directly or through further
404 * nesting, then scans forms for a reference to `$reusable_block_id` itself OR to any
405 * reusable block found in that chain.
406 *
407 * Each SQL pass is a `LIKE` scan for the ref ID as a literal string — cheap compared to
408 * running the full block parser/`Block_Helper::get_blocks_from_content()` walk (which
409 * resolves every nested reusable block via a `get_post()` call each) against every row
410 * just to check for a reference. It can return false positives (e.g. the digits appear
411 * inside an unrelated attribute value) but never false negatives, since a real
412 * `core/block` reference always serializes its `ref` as `"ref":<id>` in the block
413 * comment; each candidate form is confirmed with the real parser before being included,
414 * so a false positive only costs one extra, already-necessary parse rather than a wrong
415 * result.
416 *
417 * @since 3.6.5.3
418 *
419 * @param int $reusable_block_id
420 *
421 * @return int[]
422 */
423 private function find_forms_referencing_reusable_block( int $reusable_block_id ): array {
424 if ( $reusable_block_id <= 0 ) {
425 return array();
426 }
427
428 $relevant_block_ids = $this->find_reusable_blocks_referencing( array( $reusable_block_id ) );
429 $relevant_block_ids[ $reusable_block_id ] = true;
430
431 $candidate_ids = $this->find_posts_with_any_ref( 'jet-form-builder', array_keys( $relevant_block_ids ) );
432
433 $referencing = array();
434
435 foreach ( $candidate_ids as $candidate_id ) {
436 $post = get_post( $candidate_id );
437
438 if ( ! $post instanceof \WP_Post ) {
439 continue;
440 }
441
442 if ( $this->content_references_any_reusable_block( $post->post_content, $relevant_block_ids ) ) {
443 $referencing[] = $candidate_id;
444 }
445 }
446
447 return $referencing;
448 }
449
450 /**
451 * Transitively finds every `wp_block` post that references any ID in `$target_ids`,
452 * directly or through further nesting (C → B → A, where A is one of `$target_ids`, also
453 * returns C). Breadth-first over `LIKE` scans, one level of nesting per iteration; a
454 * `$visited` set prevents infinite recursion on a reference cycle (which `Block_Helper::
455 * walk_by_reusable()` also guards against when actually expanding content, for the same
456 * reason).
457 *
458 * @since 3.6.5.3
459 *
460 * @param int[] $target_ids
461 *
462 * @return array<int, true> Keyed by post ID for fast membership checks.
463 */
464 private function find_reusable_blocks_referencing( array $target_ids ): array {
465 $visited = array();
466 $frontier = $target_ids;
467
468 while ( ! empty( $frontier ) ) {
469 $found = $this->find_posts_with_any_ref( 'wp_block', $frontier );
470
471 $frontier = array();
472
473 foreach ( $found as $post_id ) {
474 if ( isset( $visited[ $post_id ] ) ) {
475 continue;
476 }
477
478 $visited[ $post_id ] = true;
479 $frontier[] = $post_id;
480 }
481 }
482
483 return $visited;
484 }
485
486 /**
487 * `LIKE`-scans `$post_type` posts for a `core/block` ref to any ID in `$ref_ids`,
488 * returning post IDs (not yet parser-confirmed — see `find_forms_referencing_reusable_
489 * block()`'s docblock on why the `LIKE` scan alone is sufficient to build the candidate
490 * set without false negatives).
491 *
492 * @since 3.6.5.3
493 *
494 * @param string $post_type
495 * @param int[] $ref_ids
496 *
497 * @return int[]
498 */
499 private function find_posts_with_any_ref( string $post_type, array $ref_ids ): array {
500 global $wpdb;
501
502 $ref_ids = array_values( array_unique( array_filter( array_map( 'intval', $ref_ids ) ) ) );
503
504 if ( empty( $ref_ids ) ) {
505 return array();
506 }
507
508 // Placeholder count varies with count( $ref_ids ), so the query string cannot be a
509 // single literal passed straight to $wpdb->prepare() the way PHPCS's WordPress.DB
510 // sniffs expect; both are still fully parameterized (every value — post type and
511 // every LIKE pattern — goes through prepare()'s own %s placeholders, none of them
512 // concatenated directly into the SQL string), so this is safe despite the sniffs
513 // not being able to verify a dynamically-built placeholder list.
514 $like_clauses = array_fill( 0, count( $ref_ids ), 'post_content LIKE %s' );
515 $like_values = array();
516
517 foreach ( $ref_ids as $ref_id ) {
518 $like_values[] = '%' . $wpdb->esc_like( '"ref":' . $ref_id ) . '%';
519 }
520
521 $sql = $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
522 "SELECT ID FROM {$wpdb->posts}
523 WHERE post_type = %s
524 AND post_status IN ( 'publish', 'draft', 'pending', 'private', 'future', 'trash' )
525 AND ( " . implode( ' OR ', $like_clauses ) . ' )', // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
526 array_merge( array( $post_type ), $like_values )
527 );
528
529 $ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
530
531 return array_map( 'intval', $ids );
532 }
533
534 /**
535 * Confirms a `LIKE`-matched candidate actually references any ID in `$reusable_block_ids`
536 * via a real `core/block` block, by walking the real parsed block tree rather than
537 * trusting the SQL `LIKE` match's text position. Reuses `Block_Helper::
538 * get_blocks_from_content()`'s existing reusable-block-expanding walk (the same one
539 * `Ssr_Blocked_Callback_Usages::collect_from_content()` relies on) rather than
540 * re-implementing block-tree traversal here, so the two can never drift on what counts
541 * as "references."
542 *
543 * @since 3.6.5.3
544 *
545 * @param array<int, true> $reusable_block_ids
546 */
547 private function content_references_any_reusable_block( string $post_content, array $reusable_block_ids ): bool {
548 foreach ( $this->flatten_blocks( \Jet_Form_Builder\Blocks\Block_Helper::get_blocks_from_content( $post_content ) ) as $block ) {
549 if (
550 'core/block' === ( $block['blockName'] ?? '' ) &&
551 isset( $reusable_block_ids[ (int) ( $block['attrs']['ref'] ?? 0 ) ] )
552 ) {
553 return true;
554 }
555 }
556
557 return false;
558 }
559
560 /**
561 * @param array[] $blocks
562 *
563 * @return \Generator<array>
564 */
565 private function flatten_blocks( array $blocks ): \Generator {
566 foreach ( $blocks as $block ) {
567 yield $block;
568
569 if ( ! empty( $block['innerBlocks'] ) ) {
570 yield from $this->flatten_blocks( $block['innerBlocks'] );
571 }
572 }
573 }
574
575 public function add_validation_block( Base $block ) {
576 /**
577 * If in post meta enable Advanced validation
578 * or right in block settings
579 */
580 if ( ! $this->is_advanced( $block->block_attrs ) ) {
581 return;
582 }
583
584 $this->add_validation_messages_global( '', true );
585
586 $type = $block->block_attrs['validation']['type'] ?? '';
587 $rules = $block->block_attrs['validation']['rules'] ?? array();
588
589 $block->add_attribute( 'data-validation-type', $type ?: 'inherit' );
590
591 if ( ! empty( $rules ) ) {
592 $this->get_rules()->prepare_rules( $rules );
593
594 // Security: Add signatures for SSR validation rules
595 // For repeater fields, we include the repeater name in the signature
596 // but NOT the row index (which is dynamic). The signature binds the
597 // field to its structural path: [repeater_name, field_name] or just field_name
598 $form_id = jet_fb_live()->form_id;
599 $field_name = $block->block_attrs['name'] ?? '';
600 $repeater_name = $block->get_repeater_name();
601
602 // Build canonical path for signature (without row index)
603 $signature_path = $repeater_name
604 ? array( $repeater_name, $field_name )
605 : $field_name;
606
607 foreach ( $rules as $index => &$rule ) {
608 if ( 'ssr' === ( $rule['type'] ?? '' ) ) {
609 $signature = Rest_Validation_Endpoint::generate_signature(
610 (int) $form_id,
611 $signature_path,
612 (int) $index
613 );
614 $signature_key = Validation_Handler::get_signature_key( $signature_path, (int) $index );
615
616 printf(
617 '<input type="hidden" name="%1$s[%2$s]" value="%3$s" />',
618 esc_attr( Validation_Handler::MAIN_SIGNATURES_KEY ),
619 esc_attr( $signature_key ),
620 esc_attr( $signature )
621 );
622
623 // Security: only the lookup key is exposed here, never the signature
624 // itself — the signature already lives in the hidden input above,
625 // and JS reads it from there instead of duplicating it in this
626 // public JSON blob (https://github.com/Crocoblock/issues-tracker/issues/20361).
627 $rule['_sig_key'] = $signature_key;
628
629 // Security: replace a custom callback's clean name with a stable
630 // opaque ID in this public JSON blob only. The form itself (and
631 // $block->block_attrs) still stores the clean name — the server
632 // always resolves the callback from there, never from this ID or
633 // from the request. Built-ins and the fixed-safe WP Core list are
634 // safe by construction and are left in the clear.
635 $callback_name = (string) ( $rule['value'] ?? '' );
636
637 // PHP function names are case-insensitive, and is_builtin_callback()
638 // compares against lowercase IDs — lowercase here too (matching the
639 // FIXED_SAFE check just below) so a builtin saved with non-canonical
640 // casing is still recognized as built-in and left in the clear, rather
641 // than being needlessly masked behind an opaque registry ID.
642 if (
643 '' !== $callback_name
644 && ! Server_Side_Rule::is_builtin_callback( strtolower( $callback_name ) )
645 && ! in_array( strtolower( $callback_name ), Server_Side_Rule::FIXED_SAFE, true )
646 ) {
647 $rule['value'] = Ssr_Callback_Registry::id_for_callback( $callback_name );
648 }
649 }
650 }
651 unset( $rule );
652
653 $block->add_attribute(
654 'data-validation-rules',
655 Tools::encode_json( $rules )
656 );
657 }
658
659 /**
660 * If advanced validation not enabled right in block settings
661 */
662 if ( self::FORMAT_ADVANCED !== $type ) {
663 return;
664 }
665
666 $messages = $block->block_attrs['validation']['messages'] ?? array();
667
668 if ( ! empty( $messages ) ) {
669 $block->add_attribute( 'data-validation-messages', Tools::encode_json( $messages ) );
670 }
671 }
672
673 public function get_settings(): array {
674 /** @var \JFB_Modules\Post_Type\Module $module */
675 /** @noinspection PhpUnhandledExceptionInspection */
676 $module = jet_form_builder()->module( 'post-type' );
677 $validation = $module->query_meta( Post_Type\Validation_Meta::class );
678
679 $response = array(
680 'type' => $validation['type'] ?? self::FORMAT_BROWSER,
681 'messages' => array(),
682 );
683
684 $messages = $this->get_messages();
685
686 foreach ( $messages as $message ) {
687 $response['messages'][ $message->get_id() ] = (
688 $validation['messages'][ $message->get_id() ] ?? $message->get_initial()
689 );
690 }
691
692 return $response;
693 }
694
695 public function is_advanced( array $block_attrs ): bool {
696 $type = $block_attrs['validation']['type'] ?? '';
697
698 if ( self::FORMAT_ADVANCED === $type ) {
699 return true;
700 }
701
702 if ( '' === $type || 'inherit' === $type ) {
703 return $this->is_advanced_form();
704 }
705
706 return false;
707 }
708
709 public function is_advanced_form(): bool {
710 if ( is_null( $this->settings ) ) {
711 $this->settings = $this->get_settings();
712 }
713
714 return self::FORMAT_ADVANCED === ( $this->settings['type'] ?? '' );
715 }
716
717 public function formats(): array {
718 return array(
719 array(
720 'value' => self::FORMAT_BROWSER,
721 'label' => __( 'Default', 'jet-form-builder' ),
722 'title' => __( 'Browser native validation', 'jet-form-builder' ),
723 ),
724 array(
725 'value' => self::FORMAT_ADVANCED,
726 'label' => __( 'Advanced', 'jet-form-builder' ),
727 'title' => __( 'More flexible JetFormBuilder\'s validation', 'jet-form-builder' ),
728 ),
729 );
730 }
731
732 public function validate_block( Field_Data_Parser $parser ) {
733 if (
734 ! $this->is_advanced( $parser->get_settings() ) ||
735 Tools::is_empty( $parser->get_value() ) ||
736 $parser->is_inside_conditional()
737 ) {
738 return;
739 }
740
741 $this->get_rules()->validate_block( $parser );
742 }
743
744 public function localize_editor_config() {
745 wp_localize_script(
746 Editor::EDITOR_PACKAGE_HANDLE,
747 'jetFormValidation',
748 array(
749 'messages' => Array_Tools::to_array( $this->get_messages() ),
750 'ssr_callbacks' => Array_Tools::to_array( $this->get_rules()->get_ssr()->get_callbacks() ),
751 'formats' => $this->formats(),
752 'rule_types' => Array_Tools::to_array( $this->get_rules()->rep_get_values() ),
753 'ssr_callbacks_settings_url' => Pages_Manager::instance()->get_stable_url( 'jfb-settings' ) . '#ssr-callbacks-tab',
754 )
755 );
756 }
757
758 /**
759 * @return Rules_Controller
760 */
761 public function get_rules(): Rules_Controller {
762 if ( ! is_null( $this->rules ) ) {
763 return $this->rules;
764 }
765
766 $this->rules = new Rules_Controller();
767
768 return $this->rules;
769 }
770
771 public function get_messages(): array {
772 if ( ! empty( $this->messages ) ) {
773 return $this->messages;
774 }
775
776 $this->messages = apply_filters(
777 'jet-form-builder/validation-messages',
778 array(
779 new Messages\Is_Empty_Value(),
780 new Messages\Is_Number_Min(),
781 new Messages\Is_Number_Max(),
782 new Messages\Is_Char_Min(),
783 new Messages\Is_Char_Max(),
784 new Messages\Is_Not_Valid_Email(),
785 new Messages\Is_Not_Valid_Url(),
786 new Messages\Is_Not_Complete_Mask(),
787 new Messages\Is_Files_Max(),
788 new Messages\Is_File_Size(),
789 new Messages\Is_File_Ext(),
790 new Messages\Is_Date_Min(),
791 new Messages\Is_Date_Max(),
792 )
793 );
794
795 return $this->messages;
796 }
797
798 }
799