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
jetformbuilder / modules / option-field / rest-api / generator-update-endpoint.php

generator-update-endpoint.php in JetFormBuilder — Dynamic Blocks Form Builder 3.6.6, at modules/option-field/rest-api/generator-update-endpoint.php

419 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace JFB_Modules\Option_Field\Rest_Api;
4
5 use Jet_Form_Builder\Classes\Builder_Helper;
6 use Jet_Form_Builder\Generators\Registry;
7 use Jet_Form_Builder\Plugin;
8 use JFB_Components\Rest_Api\Rest_Api_Endpoint_Base;
9
10 // If this file is called directly, abort.
11 if ( ! defined( 'WPINC' ) ) {
12 die;
13 }
14
15 /**
16 * Generator Update Endpoint.
17 *
18 * Handles dynamic field option updates based on context from other fields.
19 * Used by auto-update feature to regenerate options when dependent fields change.
20 */
21 class Generator_Update_Endpoint extends Rest_Api_Endpoint_Base {
22
23 /**
24 * Returns the REST base path for this endpoint.
25 *
26 * @return string
27 */
28 public static function get_rest_base() {
29 return 'generator-update';
30 }
31
32 /**
33 * Returns allowed HTTP methods.
34 *
35 * @return string
36 */
37 public static function get_methods() {
38 return \WP_REST_Server::CREATABLE;
39 }
40
41 /**
42 * Check permission for this endpoint.
43 *
44 * Allows public access for frontend forms since this is used by anonymous users
45 * submitting forms. Security is handled through:
46 * - Form ID validation (must be published jet-form-builder post)
47 * - Input sanitization in run_callback()
48 * - Generator registry validation
49 *
50 * @return bool
51 */
52 public function check_permission(): bool {
53 // Allow public access for frontend forms
54 // Validation is performed in run_callback() method
55 return true;
56 }
57
58 /**
59 * Handle the REST request.
60 *
61 * Expected request body:
62 * {
63 * "form_id": 123,
64 * "field_name": "my_field",
65 * "generator_id": "get_from_query",
66 * "context": { "parent_field": "parent_value" }
67 * }
68 *
69 * @param \WP_REST_Request $request REST request object.
70 *
71 * @return \WP_REST_Response
72 */
73 public function run_callback( \WP_REST_Request $request ) {
74 $body = $request->get_json_params();
75
76 $form_id = isset( $body['form_id'] ) ? absint( $body['form_id'] ) : 0;
77 $field_name = isset( $body['field_name'] ) ? sanitize_text_field( $body['field_name'] ) : '';
78 $generator_id = isset( $body['generator_id'] ) ? sanitize_text_field( $body['generator_id'] ) : '';
79 $preview_nonce = isset( $body['preview_nonce'] ) ? sanitize_text_field( $body['preview_nonce'] ) : '';
80 $context = isset( $body['context'] ) && is_array( $body['context'] ) ? $body['context'] : array();
81
82 if ( ! $form_id ) {
83 return new \WP_REST_Response(
84 array( 'success' => false, 'message' => __( 'Form ID is required.', 'jet-form-builder' ) ),
85 400
86 );
87 }
88
89 $form_post = $this->resolve_form_post( $form_id, $preview_nonce );
90 if ( ! $form_post ) {
91 return new \WP_REST_Response(
92 array( 'success' => false, 'message' => __( 'Invalid form ID.', 'jet-form-builder' ) ),
93 404
94 );
95 }
96
97 if ( ! $generator_id ) {
98 return new \WP_REST_Response(
99 array( 'success' => false, 'message' => __( 'Generator ID is required.', 'jet-form-builder' ) ),
100 400
101 );
102 }
103
104 // Load block_attrs from the saved post — never trust the client.
105 // This prevents SSRF (endpoint_url tampering) and arbitrary template injection.
106 $block_attrs = $this->get_block_attrs_from_post( $form_post, $field_name );
107
108 if ( null === $block_attrs ) {
109 return new \WP_REST_Response(
110 array( 'success' => false, 'message' => __( 'Field not found in form.', 'jet-form-builder' ) ),
111 404
112 );
113 }
114
115 // Verify that the requested generator_id matches what is saved in the post.
116 $saved_generator_id = $block_attrs['generator_function'] ?? '';
117 if ( $generator_id !== $saved_generator_id ) {
118 return new \WP_REST_Response(
119 array( 'success' => false, 'message' => __( 'Generator mismatch.', 'jet-form-builder' ) ),
120 400
121 );
122 }
123
124 $context = $this->sanitize_context( $context );
125 $block_attrs['_jfb_runtime'] = $this->build_generator_runtime( $block_attrs, $form_post, $field_name );
126
127 // Scoped context storage for integrations (preferred over $_REQUEST).
128 $had_scoped_context = array_key_exists( 'jfb_generator_context', $GLOBALS );
129 $prev_scoped_context = $had_scoped_context ? $GLOBALS['jfb_generator_context'] : null;
130 $GLOBALS['jfb_generator_context'] = $context;
131
132 // Set context in $_REQUEST for JetEngine dynamic tags compatibility.
133 // Legacy fallback, keep during transition period.
134 $context_keys_set = array();
135 foreach ( $context as $context_field => $context_value ) {
136 $_REQUEST[ 'jfb_update_related_' . $context_field ] = $context_value;
137 $context_keys_set[] = $context_field;
138 }
139
140 try {
141 $options = Registry::instance()->generate( $generator_id, $block_attrs, $context );
142
143 // Render JetEngine custom template HTML server-side if configured.
144 if ( ! empty( $block_attrs['custom_item_template'] ) && ! empty( $block_attrs['custom_item_template_id'] ) ) {
145 $builder_helper = new Builder_Helper();
146 foreach ( $options as &$option ) {
147 $object_id = $option['object_id'] ?? $option['value'] ?? null;
148 if ( $object_id ) {
149 $raw_html = $builder_helper->get_custom_template( $object_id, $block_attrs );
150 $option['html'] = wp_kses( $raw_html, $this->get_allowed_template_html() );
151 }
152 }
153 unset( $option );
154 }
155
156 return new \WP_REST_Response(
157 array( 'success' => true, 'options' => $options ),
158 200
159 );
160 } catch ( \Exception $e ) {
161 return new \WP_REST_Response(
162 array( 'success' => false, 'message' => __( 'Failed to generate options.', 'jet-form-builder' ) ),
163 500
164 );
165 } finally {
166 // Clean up $_REQUEST entries set by this request.
167 foreach ( $context_keys_set as $context_field ) {
168 unset( $_REQUEST[ 'jfb_update_related_' . $context_field ] );
169 }
170
171 // Restore previous scoped context (or unset if it didn't exist).
172 if ( $had_scoped_context ) {
173 $GLOBALS['jfb_generator_context'] = $prev_scoped_context;
174 } else {
175 unset( $GLOBALS['jfb_generator_context'] );
176 }
177 }
178 }
179
180 /**
181 * Load block attributes for a specific field from the saved post content.
182 *
183 * Returns null if the field is not found, preventing any generation
184 * with client-supplied parameters.
185 *
186 * @param \WP_Post $form_post Published form post.
187 * @param string $field_name Field name to find.
188 *
189 * @return array|null Block attrs array or null if field not found.
190 */
191 private function get_block_attrs_from_post( \WP_Post $form_post, string $field_name ): ?array {
192 if ( empty( $field_name ) ) {
193 return null;
194 }
195
196 $block = Plugin::instance()->form->get_field_by_name( $form_post->ID, $field_name );
197
198 if ( empty( $block ) || ! isset( $block['attrs'] ) ) {
199 return null;
200 }
201
202 return $block['attrs'];
203 }
204
205 /**
206 * Resolve form post for regular frontend and preview requests.
207 *
208 * Published forms are accepted directly.
209 * Revisions are accepted only in preview mode with a valid nonce and edit capability.
210 *
211 * @param int $form_id Requested form/revision ID.
212 * @param string $preview_nonce Preview nonce from request body.
213 *
214 * @return \WP_Post|null
215 */
216 private function resolve_form_post( int $form_id, string $preview_nonce ): ?\WP_Post {
217 $form_post = get_post( $form_id );
218
219 if ( $form_post && 'jet-form-builder' === $form_post->post_type && 'publish' === $form_post->post_status ) {
220 return $form_post;
221 }
222
223 if ( ! $form_post || 'revision' !== $form_post->post_type ) {
224 return null;
225 }
226
227 if ( empty( $preview_nonce ) || ! wp_verify_nonce( $preview_nonce, 'jfb-preview-form' ) ) {
228 return null;
229 }
230
231 $parent_form_id = (int) $form_post->post_parent;
232 $parent_form = $parent_form_id ? get_post( $parent_form_id ) : null;
233
234 if ( ! $parent_form || 'jet-form-builder' !== $parent_form->post_type ) {
235 return null;
236 }
237
238 if ( ! current_user_can( 'edit_jet_fb_form', $parent_form_id ) ) {
239 return null;
240 }
241
242 return $form_post;
243 }
244
245 /**
246 * Sanitize context values.
247 *
248 * @param array $context Context array with field values.
249 *
250 * @return array Sanitized context.
251 */
252 private function sanitize_context( array $context ): array {
253 $sanitized = array();
254
255 foreach ( $context as $field => $value ) {
256 $clean_field = sanitize_key( $field );
257
258 if ( is_array( $value ) ) {
259 // For array values (checkboxes, multi-select)
260 $sanitized[ $clean_field ] = array_map( 'sanitize_text_field', $value );
261 } elseif ( is_string( $value ) ) {
262 $sanitized[ $clean_field ] = sanitize_text_field( $value );
263 } elseif ( is_numeric( $value ) || is_bool( $value ) ) {
264 $sanitized[ $clean_field ] = $value;
265 }
266 }
267
268 return $sanitized;
269 }
270
271 /**
272 * Build server-side runtime metadata for generator context validation.
273 *
274 * @param array $block_attrs Target field block attributes.
275 * @param \WP_Post $form_post Form post containing the target field.
276 * @param string $field_name Target field name.
277 *
278 * @return array
279 */
280 private function build_generator_runtime( array $block_attrs, \WP_Post $form_post, string $field_name ): array {
281 $runtime = array(
282 'form_id' => (int) $form_post->ID,
283 'field_name' => $field_name,
284 );
285
286 $generator_id = $block_attrs['generator_function'] ?? '';
287 $saved_choices = $this->get_saved_listened_field_choice_values( $block_attrs, $form_post );
288
289 if ( 'get_from_db' === $generator_id ) {
290 $runtime['allowed_meta_keys'] = $saved_choices;
291 }
292
293 if ( 'get_from_users' === $generator_id ) {
294 $runtime['allowed_roles'] = $saved_choices;
295 }
296
297 return $runtime;
298 }
299
300 /**
301 * Extract saved choice values from all listened source fields.
302 *
303 * @param array $block_attrs Target field block attributes.
304 * @param \WP_Post $form_post Form post containing the target field.
305 *
306 * @return array
307 */
308 private function get_saved_listened_field_choice_values( array $block_attrs, \WP_Post $form_post ): array {
309 $choice_values = array();
310
311 foreach ( $this->get_listened_field_names( $block_attrs ) as $listen_field_name ) {
312 $listened_block = Plugin::instance()->form->get_field_by_name( $form_post->ID, $listen_field_name );
313
314 if ( empty( $listened_block ) ) {
315 continue;
316 }
317
318 $choice_values = array_merge(
319 $choice_values,
320 $this->get_saved_choice_values( $listened_block )
321 );
322 }
323
324 return array_values( array_unique( $choice_values ) );
325 }
326
327 /**
328 * Normalize saved listened field names from block attributes.
329 *
330 * @param array $block_attrs Target field block attributes.
331 *
332 * @return array
333 */
334 private function get_listened_field_names( array $block_attrs ): array {
335 $listened = $block_attrs['generator_listen_field'] ?? array();
336
337 if ( is_string( $listened ) ) {
338 $listened = '' === $listened ? array() : array( $listened );
339 }
340
341 if ( ! is_array( $listened ) ) {
342 return array();
343 }
344
345 return array_values(
346 array_filter(
347 array_map(
348 static function ( $field_name ) {
349 return sanitize_text_field( (string) $field_name );
350 },
351 $listened
352 )
353 )
354 );
355 }
356
357 /**
358 * Extract saved option values from a source field block.
359 *
360 * Custom option fields are intentionally excluded because they accept
361 * arbitrary user input and cannot provide a safe static allowlist.
362 *
363 * @param array $field_block Source field block.
364 *
365 * @return array
366 */
367 private function get_saved_choice_values( array $field_block ): array {
368 $attrs = $field_block['attrs'] ?? array();
369
370 if ( empty( $attrs['field_options'] ) || ! is_array( $attrs['field_options'] ) ) {
371 return array();
372 }
373
374 if ( ! empty( $attrs['custom_option'] ) ) {
375 return array();
376 }
377
378 $values = array();
379
380 foreach ( $attrs['field_options'] as $option ) {
381 if ( ! is_array( $option ) || ! isset( $option['value'] ) || ! is_scalar( $option['value'] ) ) {
382 continue;
383 }
384
385 $value = trim( sanitize_text_field( (string) $option['value'] ) );
386
387 if ( '' === $value ) {
388 continue;
389 }
390
391 $values[] = $value;
392 }
393
394 return $values;
395 }
396
397 /**
398 * Allowed HTML for sanitizing server-rendered Custom Template markup.
399 *
400 * Extends the default post allowlist with a bare `style` tag so that
401 * inline `<style>` blocks emitted by JetEngine listing templates
402 * (used for widget positioning/layout) survive sanitization, matching
403 * the unsanitized initial page-render path for this same trusted,
404 * admin-configured template source.
405 *
406 * @return array
407 */
408 private function get_allowed_template_html(): array {
409 return array_merge(
410 wp_kses_allowed_html( 'post' ),
411 array(
412 'style' => array(
413 'type' => true,
414 ),
415 )
416 );
417 }
418 }
419