PluginProbe
JetFormBuilder — Dynamic Blocks Form Builder / 3.6.6
JetFormBuilder — Dynamic Blocks Form Builder v3.6.6
3.6.6.1 3.6.6 3.6.5.4 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 All 134 releases
← All changes | modules/validation/module.php +393 -11 3.6.4 → 3.6.6 View file →
@@ -16,12 +16,19 @@
16 16 use JFB_Components\Module\Base_Module_Handle_Trait;
17 17 use JFB_Components\Module\Base_Module_It;
18 18 use JFB_Components\Module\Base_Module_Url_It;
19 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;
20 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;
21 26 use JFB_Modules\Validation\Class_Validation_Handlers;
22 27 use JFB_Modules\Validation\Handlers\Validation_Handler;
23 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;
24 31
25 32 // If this file is called directly, abort.
26 33 if ( ! defined( 'WPINC' ) ) {
27 34 die;
@@ -48,8 +55,12 @@
48 55 */
49 56 private $rules;
50 57 private $settings;
51 58 private $inline_messages = array();
59 + /**
60 + * @var Ssr_Registry_Migration_Notice
61 + */
62 + private $ssr_registry_migration_notice;
52 63
53 64 public function rep_item_id() {
54 65 return 'validation';
55 66 }
@@ -76,8 +87,10 @@
76 87
77 88 /** @var \JFB_Modules\Rest_Api\Module $rest_api */
78 89 $rest_api = jet_form_builder()->module( 'rest-api' );
79 90 $rest_api->get_controller()->install( new Rest_Api\Rest_Validation_Endpoint() );
91 +
92 + Tab_Handler_Manager::instance()->install( new Ssr_Callbacks_Handler() );
80 93 }
81 94
82 95 /**
83 96 * @throws Repository_Exception
@@ -92,8 +105,10 @@
92 105
93 106 /** @var \JFB_Modules\Rest_Api\Module $rest_api */
94 107 $rest_api = jet_form_builder()->module( 'rest-api' );
95 108 $rest_api->get_controller()->uninstall( new Rest_Api\Rest_Validation_Endpoint() );
109 +
110 + Tab_Handler_Manager::instance()->uninstall( 'ssr-callbacks-tab' );
96 111 }
97 112
98 113 public function init_hooks() {
99 114 add_filter(
@@ -125,8 +140,27 @@
125 140 add_action(
126 141 'jet-form-builder/editor-assets/before',
127 142 array( $this, 'localize_editor_config' )
128 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();
129 163 }
130 164
131 165 public function remove_hooks() {
132 166 remove_filter(
@@ -157,8 +191,28 @@
157 191 remove_action(
158 192 'jet-form-builder/editor-assets/before',
159 193 array( $this, 'localize_editor_config' )
160 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 + }
161 215 }
162 216
163 217 public function register_scripts() {
164 218 $script_asset = require_once jet_form_builder()->plugin_dir(
@@ -224,8 +278,301 @@
224 278
225 279 return $markup;
226 280 }
227 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 +
228 575 public function add_validation_block( Base $block ) {
229 576 /**
230 577 * If in post meta enable Advanced validation
231 578 * or right in block settings
@@ -258,20 +605,48 @@
258 605 : $field_name;
259 606
260 607 foreach ( $rules as $index => &$rule ) {
261 608 if ( 'ssr' === ( $rule['type'] ?? '' ) ) {
262 - $rule['_sig'] = Rest_Validation_Endpoint::generate_signature(
609 + $signature = Rest_Validation_Endpoint::generate_signature(
263 610 (int) $form_id,
264 611 $signature_path,
265 612 (int) $index
266 613 );
614 + $signature_key = Validation_Handler::get_signature_key( $signature_path, (int) $index );
267 615
268 616 printf(
269 617 '<input type="hidden" name="%1$s[%2$s]" value="%3$s" />',
270 618 esc_attr( Validation_Handler::MAIN_SIGNATURES_KEY ),
271 - esc_attr( Validation_Handler::get_signature_key( $signature_path, (int) $index ) ),
272 - esc_attr( $rule['_sig'] )
619 + esc_attr( $signature_key ),
620 + esc_attr( $signature )
273 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 + }
274 649 }
275 650 }
276 651 unset( $rule );
277 652
@@ -319,11 +694,17 @@
319 694
320 695 public function is_advanced( array $block_attrs ): bool {
321 696 $type = $block_attrs['validation']['type'] ?? '';
322 697
323 - return $type
324 - ? self::FORMAT_ADVANCED === $type
325 - : $this->is_advanced_form();
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;
326 707 }
327 708
328 709 public function is_advanced_form(): bool {
329 710 if ( is_null( $this->settings ) ) {
@@ -350,9 +731,9 @@
350 731
351 732 public function validate_block( Field_Data_Parser $parser ) {
352 733 if (
353 734 ! $this->is_advanced( $parser->get_settings() ) ||
354 - ! $parser->get_value() ||
735 + Tools::is_empty( $parser->get_value() ) ||
355 736 $parser->is_inside_conditional()
356 737 ) {
357 738 return;
358 739 }
@@ -364,12 +745,13 @@
364 745 wp_localize_script(
365 746 Editor::EDITOR_PACKAGE_HANDLE,
366 747 'jetFormValidation',
367 748 array(
368 - 'messages' => Array_Tools::to_array( $this->get_messages() ),
369 - 'ssr_callbacks' => Array_Tools::to_array( $this->get_rules()->get_ssr()->get_callbacks() ),
370 - 'formats' => $this->formats(),
371 - 'rule_types' => Array_Tools::to_array( $this->get_rules()->rep_get_values() ),
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',
372 754 )
373 755 );
374 756 }
375 757