PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.7
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.7
2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 All 97 releases
sureforms / inc / rest-api.php

rest-api.php in SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz 2.12.7, at inc/rest-api.php

1,941 lines 61.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Rest API Manager Class.
4 *
5 * @package sureforms.
6 */
7
8 namespace SRFM\Inc;
9
10 use SRFM\Inc\AI_Form_Builder\AI_Auth;
11 use SRFM\Inc\AI_Form_Builder\AI_Form_Builder;
12 use SRFM\Inc\AI_Form_Builder\Field_Mapping;
13 use SRFM\Inc\Database\Tables\Entries;
14 use SRFM\Inc\Entries as Entries_Class;
15 use SRFM\Inc\Traits\Get_Instance;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit; // Exit if accessed directly.
19 }
20
21 /**
22 * Rest API handler class.
23 *
24 * @since 0.0.7
25 */
26 class Rest_Api {
27 use Get_Instance;
28
29 /**
30 * Onboarding user details option key.
31 *
32 * @var string
33 */
34 private $onboarding_user_details_key = 'onboarding_user_details';
35
36 /**
37 * Dropdown counter for field name generation.
38 *
39 * @var int
40 * @since 2.0.0
41 */
42 private static $dropdown_counter = 0;
43
44 /**
45 * Constructor
46 *
47 * @since 0.0.7
48 * @return void
49 */
50 public function __construct() {
51 add_action( 'rest_api_init', [ $this, 'register_endpoints' ] );
52 }
53
54 /**
55 * Register endpoints
56 *
57 * @since 0.0.7
58 * @return void
59 */
60 public function register_endpoints() {
61
62 $prefix = 'sureforms';
63 $version_slug = 'v1';
64
65 $endpoints = $this->get_endpoints();
66
67 foreach ( $endpoints as $endpoint => $args ) {
68 register_rest_route(
69 $prefix . '/' . $version_slug,
70 $endpoint,
71 $args
72 );
73 }
74 }
75
76 /**
77 * Checks whether the value is boolean or not.
78 *
79 * @param mixed $value value to be checked.
80 * @since 0.0.8
81 * @return bool
82 */
83 public function sanitize_boolean_field( $value ) {
84 return filter_var( $value, FILTER_VALIDATE_BOOLEAN );
85 }
86
87 /**
88 * Return the visitor's detected country code.
89 *
90 * Public, read-only — resolves the country for the *current* request's IP via
91 * Helper::get_geo_country(), so the phone field can fetch it per-visitor and
92 * work on full-page-cached sites. Outbound geolocation calls are bounded by
93 * the hourly cap inside Helper::get_geo_country().
94 *
95 * @since 2.11.1
96 * @return \WP_REST_Response
97 */
98 public function get_geo_country() {
99 // Pass an empty fallback so '' unambiguously means "not confidently
100 // detected" (no CDN header and no successful IP lookup). The frontend then
101 // falls back to a privacy-safe, network-free Intl guess in the browser.
102 $country = Helper::get_geo_country( '' );
103
104 return new \WP_REST_Response(
105 [
106 'country' => $country,
107 'detected' => '' !== $country,
108 ],
109 200
110 );
111 }
112
113 /**
114 * Get the data for generating entries chart.
115 *
116 * @param \WP_REST_Request $request Full details about the request.
117 * @since 1.0.0
118 * @return array<mixed>
119 */
120 public function get_entries_chart_data( $request ) {
121 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
122
123 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
124 wp_send_json_error( __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) );
125 }
126
127 $params = $request->get_params();
128
129 if ( empty( $params ) ) {
130 wp_send_json_error( __( 'Missing required parameters.', 'sureforms' ) );
131 }
132
133 $after = is_array( $params ) && ! empty( $params['after'] ) ? sanitize_text_field( Helper::get_string_value( $params['after'] ) ) : '';
134 $before = is_array( $params ) && ! empty( $params['before'] ) ? sanitize_text_field( Helper::get_string_value( $params['before'] ) ) : '';
135
136 if ( empty( $after ) || empty( $before ) ) {
137 wp_send_json_error( __( 'Invalid date range.', 'sureforms' ) );
138 }
139
140 $form = is_array( $params ) && ! empty( $params['form'] ) ? sanitize_text_field( Helper::get_string_value( $params['form'] ) ) : '';
141
142 $where = [
143 [
144 [
145 'key' => 'created_at',
146 'value' => $after,
147 'compare' => '>=',
148 ],
149 [
150 'key' => 'created_at',
151 'value' => $before,
152 'compare' => '<=',
153 ],
154 ],
155 ];
156
157 if ( ! empty( $form ) ) {
158 $where[0][] = [
159 'key' => 'form_id',
160 'value' => $form,
161 'compare' => '=',
162 ];
163 }
164
165 return Entries::get_instance()->get_results(
166 $where,
167 'created_at',
168 [ 'ORDER BY created_at DESC' ]
169 );
170 }
171
172 /**
173 * Get the data for all the forms.
174 *
175 * @param \WP_REST_Request $request Full details about the request.
176 * @since 1.7.0
177 * @return array<mixed>
178 */
179 public function get_form_data( $request ) {
180 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
181
182 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
183 wp_send_json_error( __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) );
184 }
185
186 $forms = Helper::get_instance()->get_sureforms();
187
188 return ! empty( $forms ) ? $forms : [];
189 }
190
191 /**
192 * Search WordPress pages for async dropdowns.
193 *
194 * @param \WP_REST_Request $request Full details about the request.
195 * @since 2.5.2
196 * @return \WP_REST_Response
197 */
198 public function search_pages( $request ) {
199 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
200
201 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
202 return new \WP_REST_Response(
203 [ 'error' => __( 'Nonce verification failed.', 'sureforms' ) ],
204 403
205 );
206 }
207
208 $search = Helper::get_string_value( $request->get_param( 'search' ) );
209 $page = max( 1, (int) $request->get_param( 'page' ) );
210 $per_page = max( 1, min( 50, (int) $request->get_param( 'per_page' ) ) );
211 $query_per_page = $per_page + 1;
212 $selected_urls = $request->get_param( 'selected_urls' );
213 $selected_url = Helper::get_string_value( $request->get_param( 'selected_url' ) );
214 $selected_values = [];
215
216 if ( ! empty( $selected_url ) ) {
217 $selected_values[] = $selected_url;
218 }
219
220 if ( is_array( $selected_urls ) ) {
221 $selected_values = array_merge( $selected_values, $selected_urls );
222 }
223
224 $selected_values = array_slice( array_values( array_unique( array_filter( $selected_values ) ) ), 0, 5 );
225
226 $args = [
227 'post_type' => 'page',
228 'post_status' => 'publish',
229 's' => $search,
230 'orderby' => 'title',
231 'order' => 'ASC',
232 'posts_per_page' => $query_per_page,
233 'paged' => $page,
234 'fields' => 'ids',
235 'no_found_rows' => true,
236 'update_post_meta_cache' => false,
237 'update_post_term_cache' => false,
238 ];
239
240 add_filter( 'posts_search', [ $this, 'search_only_post_titles' ], 10, 2 );
241 try {
242 $query = new \WP_Query( $args );
243 } finally {
244 remove_filter( 'posts_search', [ $this, 'search_only_post_titles' ], 10 );
245 }
246
247 $post_ids = is_array( $query->posts )
248 ? array_map(
249 static function ( $post ): int {
250 if ( $post instanceof \WP_Post ) {
251 return absint( $post->ID );
252 }
253
254 return absint( $post );
255 },
256 $query->posts
257 )
258 : [];
259 $has_more = count( $post_ids ) > $per_page;
260 $post_ids = array_slice( $post_ids, 0, $per_page );
261
262 // Note: url_to_postid() issues one DB query per URL. Currently only a single
263 // selected_url is used in practice; if multi-URL usage grows, consider a
264 // batched WHERE guid IN (...) query instead.
265 foreach ( $selected_values as $selected_value ) {
266 $selected_id = url_to_postid( $selected_value );
267
268 if ( ! $selected_id || in_array( $selected_id, $post_ids, true ) ) {
269 continue;
270 }
271
272 if ( 'page' !== get_post_type( $selected_id ) || 'publish' !== get_post_status( $selected_id ) ) {
273 continue;
274 }
275
276 array_unshift( $post_ids, $selected_id );
277 }
278
279 $items = [];
280 foreach ( $post_ids as $post_id ) {
281 $permalink = get_permalink( $post_id );
282
283 if ( ! $permalink ) {
284 continue;
285 }
286
287 $title = get_post_field( 'post_title', $post_id );
288 $items[] = [
289 'id' => $post_id,
290 'label' => ! empty( $title ) ? wp_strip_all_tags( $title ) : (string) $post_id,
291 'value' => esc_url_raw( $permalink ),
292 ];
293 }
294
295 return new \WP_REST_Response(
296 [
297 'items' => $items,
298 'pagination' => [
299 'page' => $page,
300 'per_page' => $per_page,
301 'has_more' => $has_more,
302 ],
303 ],
304 200
305 );
306 }
307
308 /**
309 * Restrict search to post titles for dropdown lookups.
310 *
311 * @param string $search Search SQL fragment.
312 * @param \WP_Query $wp_query Current WP_Query.
313 * @since 2.5.2
314 * @return string
315 */
316 public function search_only_post_titles( $search, $wp_query ) {
317 global $wpdb;
318
319 if ( ! empty( $search ) && ! empty( $wp_query->query_vars['search_terms'] ) ) {
320 $query_vars = $wp_query->query_vars;
321 $wild = ! empty( $query_vars['exact'] ) ? '' : '%';
322 $search_sql = [];
323
324 foreach ( (array) $query_vars['search_terms'] as $term ) {
325 $search_sql[] = $wpdb->prepare(
326 "{$wpdb->posts}.post_title LIKE %s",
327 $wild . $wpdb->esc_like( $term ) . $wild
328 );
329 }
330
331 $search = ' AND ' . implode( ' AND ', $search_sql );
332 }
333
334 return $search;
335 }
336
337 /**
338 * Set onboarding completion status.
339 *
340 * @param \WP_REST_Request $request Full details about the request.
341 * @since 1.9.1
342 * @return \WP_REST_Response
343 */
344 public function set_onboarding_status( $request ) {
345 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
346
347 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
348 return new \WP_REST_Response(
349 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
350 403
351 );
352 }
353
354 // Set the onboarding status to yes always.
355 Onboarding::get_instance()->set_onboarding_status( 'yes' );
356
357 // Get analytics data from request.
358 $analytics_data = $request->get_param( 'analyticsData' );
359
360 // Save analytics data if provided.
361 if ( $analytics_data ) {
362 // Use Helper::update_srfm_option instead of update_option.
363 Helper::update_srfm_option( 'onboarding_analytics', $analytics_data );
364 }
365
366 return new \WP_REST_Response( [ 'success' => true ] );
367 }
368
369 /**
370 * Get onboarding completion status.
371 *
372 * @param \WP_REST_Request $request Full details about the request.
373 * @since 1.9.1
374 * @return \WP_REST_Response
375 */
376 public function get_onboarding_status( $request ) {
377 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
378
379 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
380 return new \WP_REST_Response(
381 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
382 403
383 );
384 }
385
386 $status = Onboarding::get_instance()->get_onboarding_status();
387
388 return new \WP_REST_Response( [ 'completed' => $status ] );
389 }
390
391 /**
392 * Save onboarding user details and send lead data to metrics server.
393 *
394 * @since 2.5.3
395 * @param \WP_REST_Request $request Full details about the request.
396 * @return \WP_REST_Response
397 */
398 public function save_onboarding_user_details( $request ) {
399 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
400
401 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
402 return new \WP_REST_Response(
403 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
404 403
405 );
406 }
407
408 $first_name = sanitize_text_field( Helper::get_string_value( $request->get_param( 'first_name' ) ) );
409 $last_name = sanitize_text_field( Helper::get_string_value( $request->get_param( 'last_name' ) ) );
410 $email = sanitize_email( Helper::get_string_value( $request->get_param( 'email' ) ) );
411
412 if ( empty( $first_name ) || empty( $email ) || ! is_email( $email ) ) {
413 return new \WP_REST_Response(
414 [ 'error' => __( 'Invalid onboarding user details.', 'sureforms' ) ],
415 400
416 );
417 }
418
419 $stored_details = $this->get_onboarding_user_details();
420 if ( ! empty( $stored_details['lead'] ) ) {
421 return new \WP_REST_Response(
422 [
423 'success' => true,
424 'lead' => true,
425 ]
426 );
427 }
428
429 $this->set_onboarding_user_details(
430 [
431 'first_name' => $first_name,
432 'last_name' => $last_name,
433 'email' => $email,
434 ]
435 );
436
437 $domain = wp_parse_url( home_url(), PHP_URL_HOST );
438 if ( ! is_string( $domain ) ) {
439 $domain = '';
440 }
441
442 $body = wp_json_encode(
443 [
444 // Lowercase keys satisfy current BSF Metrics REST arg validation.
445 'email' => $email,
446 'first_name' => $first_name,
447 'last_name' => $last_name,
448 'domain' => $domain,
449 'source' => 'sureforms',
450 // Keep legacy uppercase keys for backward compatibility.
451 'EMAIL' => $email,
452 'FIRSTNAME' => $first_name,
453 'LASTNAME' => $last_name,
454 'DOMAIN' => $domain,
455 ]
456 );
457
458 $lead_captured = false;
459 if ( false !== $body ) {
460 $response = wp_remote_post(
461 'https://metrics.brainstormforce.com/wp-json/bsf-metrics-server/v1/subscribe',
462 [
463 'headers' => [
464 'Content-Type' => 'application/json',
465 ],
466 'body' => $body,
467 'timeout' => 15,
468 ]
469 );
470
471 $response_code = wp_remote_retrieve_response_code( $response );
472 if ( ! is_wp_error( $response ) && in_array( $response_code, [ 200, 201, 204 ], true ) ) {
473 $lead_captured = true;
474 }
475 }
476
477 if ( $lead_captured ) {
478 $this->set_onboarding_user_details(
479 [
480 'first_name' => $first_name,
481 'last_name' => $last_name,
482 'email' => $email,
483 'lead' => true,
484 ]
485 );
486 }
487
488 return new \WP_REST_Response(
489 [
490 'success' => true,
491 'lead' => $lead_captured,
492 ]
493 );
494 }
495
496 /**
497 * Get plugin status for specified plugin.
498 *
499 * @param \WP_REST_Request $request Full details about the request.
500 * @since 1.9.1
501 * @return \WP_REST_Response
502 */
503 public function get_plugin_status( $request ) {
504 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
505
506 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
507 return new \WP_REST_Response(
508 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
509 403
510 );
511 }
512
513 $params = $request->get_params();
514 $plugin_slug = is_array( $params ) && isset( $params['plugin'] ) ?
515 sanitize_text_field( Helper::get_string_value( $params['plugin'] ) ) : '';
516
517 if ( empty( $plugin_slug ) ) {
518 return new \WP_REST_Response(
519 [ 'error' => __( 'Plugin identifier is required.', 'sureforms' ) ],
520 400
521 );
522 }
523
524 $integrations = Helper::sureforms_get_integration();
525
526 if ( ! isset( $integrations[ $plugin_slug ] ) ) {
527 return new \WP_REST_Response(
528 [ 'error' => __( 'Integration not found.', 'sureforms' ) ],
529 404
530 );
531 }
532
533 $plugin_data = $integrations[ $plugin_slug ];
534
535 // Get fresh status.
536 if ( is_array( $plugin_data ) && isset( $plugin_data['path'] ) ) {
537 $plugin_data['status'] = Helper::get_plugin_status( Helper::get_string_value( $plugin_data['path'] ) );
538 }
539
540 return new \WP_REST_Response( $plugin_data );
541 }
542
543 /**
544 * Sanitize entry IDs.
545 *
546 * @param mixed $value Value to sanitize.
547 * @since 2.0.0
548 * @return array<int>
549 */
550 public function sanitize_entry_ids( $value ) {
551 if ( is_array( $value ) ) {
552 return array_filter( array_map( 'absint', $value ) );
553 }
554 if ( is_numeric( $value ) ) {
555 return [ absint( $value ) ];
556 }
557 if ( is_string( $value ) ) {
558 // Handle comma-separated values.
559 $ids = explode( ',', $value );
560 return array_filter( array_map( 'absint', $ids ) );
561 }
562 return [];
563 }
564
565 /**
566 * Validate read action parameter.
567 *
568 * @param string $param Action parameter value.
569 * @since 2.0.0
570 * @return bool
571 */
572 public function validate_read_action( $param ) {
573 return in_array( $param, [ 'read', 'unread' ], true );
574 }
575
576 /**
577 * Validate trash action parameter.
578 *
579 * @param string $param Action parameter value.
580 * @since 2.0.0
581 * @return bool
582 */
583 public function validate_trash_action( $param ) {
584 return in_array( $param, [ 'trash', 'restore' ], true );
585 }
586
587 /**
588 * Get entries list with filters and pagination.
589 *
590 * @param \WP_REST_Request $request Full details about the request.
591 * @since 2.0.0
592 * @return \WP_REST_Response
593 */
594 public function get_entries_list( $request ) {
595 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
596
597 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
598 return new \WP_REST_Response(
599 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
600 403
601 );
602 }
603
604 $params = $request->get_params();
605
606 $args = [
607 'form_id' => isset( $params['form_id'] ) ? absint( $params['form_id'] ) : 0,
608 'status' => isset( $params['status'] ) ? sanitize_text_field( $params['status'] ) : 'all',
609 'search' => isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : '',
610 'date_from' => isset( $params['date_from'] ) ? sanitize_text_field( $params['date_from'] ) : '',
611 'date_to' => isset( $params['date_to'] ) ? sanitize_text_field( $params['date_to'] ) : '',
612 'orderby' => isset( $params['orderby'] ) ? sanitize_text_field( $params['orderby'] ) : 'created_at',
613 'order' => isset( $params['order'] ) ? sanitize_text_field( $params['order'] ) : 'DESC',
614 'per_page' => isset( $params['per_page'] ) ? absint( $params['per_page'] ) : 20,
615 'page' => isset( $params['page'] ) ? absint( $params['page'] ) : 1,
616 ];
617
618 $result = Entries_Class::get_entries( $args );
619
620 // Add form permalink to each entry.
621 if ( isset( $result['entries'] ) && is_array( $result['entries'] ) ) {
622 foreach ( $result['entries'] as &$entry ) {
623 if ( isset( $entry['form_id'] ) ) {
624 $entry['form_permalink'] = get_permalink( absint( $entry['form_id'] ) );
625 }
626 }
627 }
628
629 return new \WP_REST_Response( $result, 200 );
630 }
631
632 /**
633 * Update entries read status (read/unread).
634 *
635 * @param \WP_REST_Request $request Full details about the request.
636 * @since 2.0.0
637 * @return \WP_REST_Response
638 */
639 public function update_entries_read_status( $request ) {
640 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
641
642 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
643 return new \WP_REST_Response(
644 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
645 403
646 );
647 }
648
649 $entry_ids = $request->get_param( 'entry_ids' );
650 $action = $request->get_param( 'action' );
651
652 if ( empty( $entry_ids ) ) {
653 return new \WP_REST_Response(
654 [ 'error' => __( 'Select at least one entry.', 'sureforms' ) ],
655 400
656 );
657 }
658
659 if ( empty( $action ) ) {
660 return new \WP_REST_Response(
661 [ 'error' => __( 'Action is required.', 'sureforms' ) ],
662 400
663 );
664 }
665
666 // Validate action.
667 if ( ! $this->validate_read_action( $action ) ) {
668 return new \WP_REST_Response(
669 [ 'error' => __( 'Invalid action. Use "read" or "unread".', 'sureforms' ) ],
670 400
671 );
672 }
673
674 $result = Entries_Class::update_status( $entry_ids, $action );
675
676 $status_code = $result['success'] ? 200 : 400;
677
678 return new \WP_REST_Response( $result, $status_code );
679 }
680
681 /**
682 * Update entries trash status (trash/restore).
683 *
684 * @param \WP_REST_Request $request Full details about the request.
685 * @since 2.0.0
686 * @return \WP_REST_Response
687 */
688 public function update_entries_trash_status( $request ) {
689 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
690
691 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
692 return new \WP_REST_Response(
693 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
694 403
695 );
696 }
697
698 $entry_ids = $request->get_param( 'entry_ids' );
699 $action = $request->get_param( 'action' );
700
701 if ( empty( $entry_ids ) ) {
702 return new \WP_REST_Response(
703 [ 'error' => __( 'Select at least one entry.', 'sureforms' ) ],
704 400
705 );
706 }
707
708 if ( empty( $action ) ) {
709 return new \WP_REST_Response(
710 [ 'error' => __( 'Action is required.', 'sureforms' ) ],
711 400
712 );
713 }
714
715 // Validate action.
716 if ( ! $this->validate_trash_action( $action ) ) {
717 return new \WP_REST_Response(
718 [ 'error' => __( 'Invalid action. Use "trash" or "restore".', 'sureforms' ) ],
719 400
720 );
721 }
722
723 $result = Entries_Class::update_status( $entry_ids, $action );
724
725 $status_code = $result['success'] ? 200 : 400;
726
727 return new \WP_REST_Response( $result, $status_code );
728 }
729
730 /**
731 * Permanently delete entries.
732 *
733 * @param \WP_REST_Request $request Full details about the request.
734 * @since 2.0.0
735 * @return \WP_REST_Response
736 */
737 public function delete_entries( $request ) {
738 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
739
740 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
741 return new \WP_REST_Response(
742 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
743 403
744 );
745 }
746
747 $entry_ids = $request->get_param( 'entry_ids' );
748
749 if ( empty( $entry_ids ) ) {
750 return new \WP_REST_Response(
751 [ 'error' => __( 'Select at least one entry.', 'sureforms' ) ],
752 400
753 );
754 }
755
756 $result = Entries_Class::delete_entries( $entry_ids );
757
758 $status_code = $result['success'] ? 200 : 400;
759
760 return new \WP_REST_Response( $result, $status_code );
761 }
762
763 /**
764 * Get entry details with form data, submission info, and metadata.
765 *
766 * @param \WP_REST_Request $request Full details about the request.
767 * @since 2.0.0
768 * @return \WP_REST_Response
769 */
770 public function get_entry_details( $request ) {
771 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
772
773 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
774 return new \WP_REST_Response(
775 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
776 403
777 );
778 }
779
780 $entry_id = absint( $request->get_param( 'id' ) );
781
782 if ( empty( $entry_id ) ) {
783 return new \WP_REST_Response(
784 [ 'error' => __( 'Entry ID is required.', 'sureforms' ) ],
785 400
786 );
787 }
788
789 $entry = Entries::get( $entry_id );
790
791 if ( ! $entry ) {
792 return new \WP_REST_Response(
793 [ 'error' => __( 'Entry not found.', 'sureforms' ) ],
794 404
795 );
796 }
797
798 // Get adjacent entry IDs for navigation scoped to the same form.
799 $form_id_raw = $entry['form_id'] ?? 0;
800 $adjacent_entries = Entries_Class::get_adjacent_entry_ids( $entry_id, [ 'form_id' => is_scalar( $form_id_raw ) ? absint( $form_id_raw ) : 0 ] );
801
802 // Process form data.
803 $form_data = [];
804 $excluded_fields = [ 'srfm-honeypot-field', 'g-recaptcha-response', 'srfm-sender-email-field' ];
805 $entry_form_data = $entry['form_data'] ?? [];
806
807 if ( is_array( $entry_form_data ) ) {
808 foreach ( $entry_form_data as $field_name => $value ) {
809 if ( ! is_string( $field_name ) || in_array( $field_name, $excluded_fields, true ) ) {
810 continue;
811 }
812 if ( false === str_contains( $field_name, '-lbl-' ) ) {
813 continue;
814 }
815
816 $label_parts = explode( '-lbl-', $field_name );
817 $label = isset( $label_parts[1] ) ? explode( '-', $label_parts[1] )[0] : '';
818 $label = $label ? Helper::decode( $label ) : '';
819 $field_block_name = Helper::get_block_name_from_field( $field_name );
820
821 /**
822 * Filter: 'srfm_entry_value'
823 *
824 * This filter is used to allow 3rd party plugins or custom code to modify
825 * the entry field value in the entry details REST API response, if required.
826 * For example, you may want to format, mask, or otherwise transform sensitive
827 * data before output. Note the value reaching this filter is not encrypted by
828 * SureForms — labels and values are carried as unkeyed base64 at most.
829 *
830 * @since 2.0.0
831 *
832 * @param mixed $value The original value for the field.
833 * @param array $context An array of context, including:
834 * - field_name (string)
835 * - label (string)
836 * - field_block_name (string)
837 *
838 * @return mixed
839 */
840 $value = apply_filters(
841 'srfm_entry_value',
842 $value,
843 [
844 'field_name' => $field_name,
845 'label' => $label,
846 'field_block_name' => $field_block_name,
847 ]
848 );
849
850 $form_data[] = [
851 'field_name' => $field_name,
852 'label' => $label,
853 'value' => $value,
854 'block_name' => $field_block_name,
855 ];
856 }
857 }
858
859 // Get user info.
860 $user_id = Helper::get_integer_value( $entry['user_id'] );
861 $user_info = 0 !== $user_id ? get_userdata( $user_id ) : null;
862
863 // Get form info.
864 $form_title = get_post_field( 'post_title', $entry['form_id'] );
865 // Translators: %d is the form ID.
866 $form_name = ! empty( $form_title ) ? $form_title : sprintf( __( 'SureForms Form #%d', 'sureforms' ), intval( $entry['form_id'] ) );
867
868 // Parse form content to get structured field data.
869 $form_content = get_post_field( 'post_content', $entry['form_id'] );
870 $form_fields = $this->parse_form_fields( $form_content, $entry['form_data'] ?? [] );
871
872 $response_data = [
873 'id' => $entry_id,
874 'form_id' => $entry['form_id'],
875 'form_name' => $form_name,
876 'form_permalink' => get_permalink( $entry['form_id'] ),
877 'status' => $entry['status'],
878 'created_at' => $entry['created_at'],
879 'form_data' => $form_data,
880 'form_content' => $form_fields,
881 'submission_info' => [
882 'user_ip' => $entry['submission_info']['user_ip'] ?? '',
883 'browser_name' => $entry['submission_info']['browser_name'] ?? '',
884 'device_name' => $entry['submission_info']['device_name'] ?? '',
885 // Re-sanitize at the exposure boundary in case the stored value
886 // was written by a future code path that bypasses form-submit.php.
887 'submission_url' => esc_url_raw( $entry['submission_info']['submission_url'] ?? '', [ 'http', 'https' ] ),
888 ],
889 'user' => $user_info ? [
890 'id' => $user_id,
891 'display_name' => $user_info->display_name,
892 'profile_url' => get_author_posts_url( $user_id ),
893 ] : null,
894 'extras' => $entry['extras'] ?? [],
895 'navigation' => [
896 'previous_entry_id' => $adjacent_entries['previous_id'] ?? null,
897 'next_entry_id' => $adjacent_entries['next_id'] ?? null,
898 ],
899 ];
900
901 return new \WP_REST_Response( $response_data, 200 );
902 }
903
904 /**
905 * Get entry logs with pagination support.
906 *
907 * @param \WP_REST_Request $request Full details about the request.
908 * @since 2.0.0
909 * @return \WP_REST_Response
910 */
911 public function get_entry_logs( $request ) {
912 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
913
914 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
915 return new \WP_REST_Response(
916 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
917 403
918 );
919 }
920
921 $entry_id = absint( $request->get_param( 'id' ) );
922 $per_page = absint( $request->get_param( 'per_page' ) );
923 $per_page = $per_page ? $per_page : 3;
924 $page = absint( $request->get_param( 'page' ) );
925 $page = $page ? $page : 1;
926
927 if ( empty( $entry_id ) ) {
928 return new \WP_REST_Response(
929 [ 'error' => __( 'Entry ID is required.', 'sureforms' ) ],
930 400
931 );
932 }
933
934 $entry = Entries::get( $entry_id );
935
936 if ( ! $entry ) {
937 return new \WP_REST_Response(
938 [ 'error' => __( 'Entry not found.', 'sureforms' ) ],
939 404
940 );
941 }
942
943 $logs = $entry['logs'] ?? [];
944 $logs = is_array( $logs ) ? $logs : [];
945 $total_logs = count( $logs );
946 $total_pages = ceil( $total_logs / $per_page );
947 $offset = ( $page - 1 ) * $per_page;
948
949 // Paginate logs.
950 $paginated_logs = array_slice( $logs, $offset, $per_page );
951
952 // Format logs with unique IDs for deletion.
953 $formatted_logs = [];
954 foreach ( $paginated_logs as $index => $log ) {
955 if ( ! is_array( $log ) ) {
956 continue;
957 }
958 $formatted_log = [
959 'id' => $offset + $index, // Use offset-based ID for consistent deletion.
960 'title' => $log['title'] ?? '',
961 'timestamp' => $log['timestamp'] ?? time(),
962 'messages' => $log['messages'] ?? [],
963 ];
964
965 // Pass through (sanitized) retry metadata so an integration/webhook log row can
966 // offer a "Retry" action for that specific failed trigger. Set by the Pro
967 // webhook / native-integration dispatchers as [ 'type' => webhook|native, 'id' => <trigger id> ].
968 if ( isset( $log['retry'] ) && is_array( $log['retry'] ) && ! empty( $log['retry']['type'] ) && isset( $log['retry']['id'] ) ) {
969 $formatted_log['retry'] = [
970 'type' => sanitize_text_field( Helper::get_string_value( $log['retry']['type'] ) ),
971 'id' => sanitize_text_field( Helper::get_string_value( $log['retry']['id'] ) ),
972 ];
973 }
974
975 $formatted_logs[] = $formatted_log;
976 }
977
978 $response_data = [
979 'logs' => $formatted_logs,
980 'current_page' => $page,
981 'per_page' => $per_page,
982 'total' => $total_logs,
983 'total_pages' => $total_pages,
984 ];
985
986 return new \WP_REST_Response( $response_data, 200 );
987 }
988
989 /**
990 * Export entries to CSV or ZIP.
991 *
992 * @param \WP_REST_Request $request Full details about the request.
993 * @since 2.0.0
994 * @return \WP_REST_Response
995 */
996 public function export_entries( $request ) {
997 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
998
999 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
1000 return new \WP_REST_Response(
1001 [ 'error' => __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ) ],
1002 403
1003 );
1004 }
1005
1006 $params = $request->get_params();
1007
1008 $args = [
1009 'entry_ids' => isset( $params['entry_ids'] ) ? $this->sanitize_entry_ids( $params['entry_ids'] ) : [],
1010 'form_id' => isset( $params['form_id'] ) ? absint( $params['form_id'] ) : 0,
1011 'status' => isset( $params['status'] ) ? sanitize_text_field( $params['status'] ) : 'all',
1012 'search' => isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : '',
1013 'date_from' => isset( $params['date_from'] ) ? sanitize_text_field( $params['date_from'] ) : '',
1014 'date_to' => isset( $params['date_to'] ) ? sanitize_text_field( $params['date_to'] ) : '',
1015 ];
1016
1017 /**
1018 * Export result with success status and either error message or file details.
1019 *
1020 * @var array{success: false, error: string} | array{success: true, filename: string, filepath: string, type: string} $result
1021 */
1022 $result = Entries_Class::export_entries( $args );
1023
1024 if ( ! $result['success'] ) {
1025 return new \WP_REST_Response(
1026 [ 'error' => $result['error'] ],
1027 400
1028 );
1029 }
1030
1031 // Return file information for download.
1032 $filepath = Helper::get_string_value( $result['filepath'] );
1033 return new \WP_REST_Response(
1034 [
1035 'success' => true,
1036 'filename' => $result['filename'],
1037 'filepath' => $result['filepath'],
1038 'type' => $result['type'],
1039 'download_url' => add_query_arg(
1040 '_wpnonce',
1041 wp_create_nonce( 'srfm_download_export' ),
1042 admin_url( 'admin-ajax.php?action=srfm_download_export&file=' . rawurlencode( basename( $filepath ) ) )
1043 ),
1044 ],
1045 200
1046 );
1047 }
1048 /**
1049 * Manage form lifecycle operations (trash, restore, delete).
1050 *
1051 * @param \WP_REST_Request $request Full details about the request.
1052 * @since 2.0.0
1053 * @return \WP_REST_Response|\WP_Error
1054 */
1055 public function manage_form_lifecycle( $request ) {
1056 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
1057
1058 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
1059 return new \WP_Error(
1060 'invalid_nonce',
1061 __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
1062 [ 'status' => 403 ]
1063 );
1064 }
1065
1066 $params = $request->get_params();
1067 $form_ids = isset( $params['form_ids'] ) && is_array( $params['form_ids'] ) ?
1068 array_map( 'intval', $params['form_ids'] ) :
1069 [ intval( $params['form_ids'] ) ];
1070 $action = isset( $params['action'] ) ? sanitize_text_field( Helper::get_string_value( $params['action'] ) ) : '';
1071
1072 if ( empty( $form_ids ) || empty( $action ) ) {
1073 return new \WP_Error(
1074 'missing_parameters',
1075 __( 'Select at least one form and specify an action.', 'sureforms' ),
1076 [ 'status' => 400 ]
1077 );
1078 }
1079
1080 $results = [];
1081 $errors = [];
1082
1083 foreach ( $form_ids as $form_id ) {
1084 $post = get_post( $form_id );
1085
1086 // Validate that the post exists and is a sureforms_form.
1087 if ( ! $post || 'sureforms_form' !== $post->post_type ) {
1088 $errors[] = [
1089 'form_id' => $form_id,
1090 'error' => __( 'Form not found or is not a valid form type.', 'sureforms' ),
1091 ];
1092 continue;
1093 }
1094
1095 $result = false;
1096
1097 switch ( $action ) {
1098 case 'trash':
1099 if ( 'trash' === $post->post_status ) {
1100 $errors[] = [
1101 'form_id' => $form_id,
1102 'error' => __( 'This form is already in the trash.', 'sureforms' ),
1103 ];
1104 } else {
1105 $result = wp_trash_post( $form_id );
1106 }
1107 break;
1108
1109 case 'restore':
1110 if ( 'trash' !== $post->post_status ) {
1111 $errors[] = [
1112 'form_id' => $form_id,
1113 'error' => __( 'This form is not in the trash.', 'sureforms' ),
1114 ];
1115 } else {
1116 $result = wp_untrash_post( $form_id );
1117 }
1118 break;
1119
1120 case 'delete':
1121 // Force delete permanently.
1122 $result = wp_delete_post( $form_id, true );
1123 break;
1124
1125 case 'draft':
1126 if ( 'trash' === $post->post_status ) {
1127 $errors[] = [
1128 'form_id' => $form_id,
1129 'error' => __( 'Use the restore action to recover a trashed form before switching it to draft.', 'sureforms' ),
1130 ];
1131 } elseif ( 'draft' === $post->post_status ) {
1132 $errors[] = [
1133 'form_id' => $form_id,
1134 'error' => __( 'This form is already a draft.', 'sureforms' ),
1135 ];
1136 } else {
1137 $result = wp_update_post(
1138 [
1139 'ID' => $form_id,
1140 'post_status' => 'draft',
1141 ]
1142 );
1143 }
1144 break;
1145
1146 default:
1147 $errors[] = [
1148 'form_id' => $form_id,
1149 'error' => __( 'Invalid action.', 'sureforms' ),
1150 ];
1151 break;
1152 }
1153
1154 if ( $result ) {
1155 $results[] = [
1156 'form_id' => $form_id,
1157 'action' => $action,
1158 'success' => true,
1159 ];
1160 } elseif ( ! isset( $errors[ array_search( $form_id, array_column( $errors, 'form_id' ), true ) ] ) ) {
1161 $errors[] = [
1162 'form_id' => $form_id,
1163 /* translators: %s: action name */
1164 'error' => sprintf( __( 'Failed to %s this form. Please try again.', 'sureforms' ), $action ),
1165 ];
1166 }
1167 }
1168
1169 $response_data = [
1170 'success' => ! empty( $results ),
1171 'action' => $action,
1172 'processed_ids' => array_column( $results, 'form_id' ),
1173 'success_count' => count( $results ),
1174 'results' => $results,
1175 ];
1176
1177 if ( ! empty( $errors ) ) {
1178 $response_data['errors'] = $errors;
1179 $response_data['error_count'] = count( $errors );
1180 }
1181
1182 return new \WP_REST_Response( $response_data );
1183 }
1184
1185 /**
1186 * Recursively extract form fields from blocks.
1187 *
1188 * @param array<mixed> $blocks The blocks array.
1189 * @param array<string, array<mixed>> $sureforms_blocks Registered SureForms block attributes.
1190 * @param array<string, array<mixed>> &$form_fields Reference to form fields array.
1191 * @param array<mixed> $entry_data The entry form data.
1192 * @param bool $is_special_block Whether the current block is a special block (like address).
1193 * @param int|null $base_counter Base counter for unique field naming.
1194 * @since 2.0.0
1195 * @return void
1196 */
1197 public function extract_form_fields( $blocks, $sureforms_blocks, &$form_fields, $entry_data = [], $is_special_block = false, $base_counter = null ) {
1198 if ( null !== $base_counter ) {
1199 self::$dropdown_counter = $base_counter;
1200 }
1201 $block_type = '';
1202
1203 foreach ( $blocks as $block ) {
1204 if ( ! is_array( $block ) || ! isset( $block['blockName'] ) || ! is_string( $block['blockName'] ) ) {
1205 continue;
1206 }
1207
1208 // Check if it's a SureForms block.
1209 if ( strpos( $block['blockName'], 'srfm/' ) === 0 ) {
1210 $block_type = str_replace( 'srfm/', '', $block['blockName'] );
1211 // Skip inline button or fields inside nested blocks except address.
1212 if ( 'inline-button' === $block_type || ( $is_special_block && 'address' !== $block_type ) ) {
1213 continue;
1214 }
1215
1216 if ( isset( $sureforms_blocks[ $block_type ] ) && is_array( $sureforms_blocks[ $block_type ] ) ) {
1217 $block_attributes = isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : [];
1218 $default_attributes = $sureforms_blocks[ $block_type ];
1219
1220 // Merge block instance attributes with defaults.
1221 $merged_attributes = [];
1222 foreach ( $default_attributes as $attr_name => $attr_config ) {
1223 if ( ! is_string( $attr_name ) ) {
1224 continue;
1225 }
1226 $default_value = null;
1227 if ( is_array( $attr_config ) && isset( $attr_config['default'] ) ) {
1228 $default_value = $attr_config['default'];
1229 }
1230 $merged_attributes[ $attr_name ] = $block_attributes[ $attr_name ] ?? $default_value;
1231 }
1232
1233 // Generate field name.
1234 $label = $merged_attributes['label'] ?? '';
1235 $label = is_string( $label ) ? $label : '';
1236 $slug = $merged_attributes['slug'] ?? '';
1237 $slug = is_string( $slug ) ? $slug : '';
1238 $block_id = $merged_attributes['block_id'] ?? '';
1239 $block_id = is_string( $block_id ) ? $block_id : '';
1240 $field_name = '';
1241 $base_field_name = '';
1242
1243 if ( ! empty( $label ) && ! empty( $slug ) && ! empty( $block_id ) ) {
1244 $input_label = '-lbl-' . Helper::encode( $label );
1245 $base_field_name = $input_label . '-' . $slug;
1246
1247 // Handle special case for dropdown with instance counter.
1248 if ( 'dropdown' === $block_type ) {
1249 self::$dropdown_counter++;
1250 $unique_slug = $block_type . '-' . self::$dropdown_counter;
1251 $field_name = 'srfm-' . $unique_slug . '-' . $block_id . $base_field_name;
1252 } elseif ( 'multi-choice' === $block_type ) {
1253 // Multi-choice uses standard pattern.
1254 $field_name = 'srfm-input-' . $block_type . '-' . $block_id . $base_field_name;
1255 } else {
1256 // Standard field name for other blocks.
1257 $field_name = 'srfm-' . $block_type . '-' . $block_id . $base_field_name;
1258 }
1259 }
1260
1261 // Allow pro plugin to modify field_name.
1262 $field_name = apply_filters( 'srfm_extract_form_fields_field_name', $field_name, $base_field_name, $block_type, $block_id );
1263
1264 // Get the value from entry data or use default.
1265 $field_value = $entry_data[ $field_name ] ?? ( $merged_attributes['defaultValue'] ?? '' );
1266
1267 // Special handling for address blocks - extract inner fields.
1268 if ( 'address' === $block_type && isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
1269 $inner_fields = [];
1270 $this->extract_form_fields( $block['innerBlocks'], $sureforms_blocks, $inner_fields, $entry_data, false );
1271 $field_value = $inner_fields;
1272 }
1273
1274 // Allow plugins to handle special blocks.
1275 $field_value = apply_filters( 'srfm_handle_special_block', $field_value, $block_type, $block, $sureforms_blocks, $this );
1276
1277 $form_fields[] = [
1278 'field_name' => $field_name,
1279 'block_name' => 'multi-choice' === $block_type ? 'srfm-multi' : Helper::get_block_name_from_field( $field_name ),
1280 'value' => $field_value,
1281 'attributes' => $merged_attributes,
1282 ];
1283 }
1284 }
1285
1286 // Recursively process inner blocks but skip for address blocks.
1287 if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) && ! empty( $block['innerBlocks'] ) && 'address' !== $block_type ) {
1288 // Pass true if current block has inner blocks and it doesn't need to be duplicated in the main fields array.
1289 $inner_is_special_block = apply_filters( 'srfm_is_special_block', false, $block_type );
1290 $this->extract_form_fields( $block['innerBlocks'], $sureforms_blocks, $form_fields, $entry_data, $inner_is_special_block );
1291 }
1292 }
1293 }
1294
1295 /**
1296 * Get current dropdown counter value.
1297 *
1298 * @since 2.0.0
1299 * @return int Current dropdown counter value.
1300 */
1301 public function get_dropdown_counter() {
1302 return self::$dropdown_counter;
1303 }
1304
1305 /**
1306 * Get onboarding user details.
1307 *
1308 * @since 2.5.3
1309 * @return array<string, mixed>
1310 */
1311 private function get_onboarding_user_details() {
1312 $defaults = [
1313 'first_name' => '',
1314 'last_name' => '',
1315 'email' => '',
1316 'lead' => false,
1317 ];
1318
1319 $user_details = Helper::get_srfm_option( $this->onboarding_user_details_key, $defaults );
1320 if ( ! is_array( $user_details ) ) {
1321 return $defaults;
1322 }
1323
1324 return wp_parse_args( $user_details, $defaults );
1325 }
1326
1327 /**
1328 * Set onboarding user details.
1329 *
1330 * @since 2.5.3
1331 * @param array<string, mixed> $user_details User details to store.
1332 * @return void
1333 */
1334 private function set_onboarding_user_details( $user_details ) {
1335 $details = wp_parse_args(
1336 $user_details,
1337 [
1338 'first_name' => '',
1339 'last_name' => '',
1340 'email' => '',
1341 'lead' => false,
1342 ]
1343 );
1344
1345 Helper::update_srfm_option( $this->onboarding_user_details_key, $details );
1346 }
1347
1348 /**
1349 * Parse form content and return structured field data with attributes.
1350 *
1351 * @param string $form_content The form post content.
1352 * @param array<mixed> $entry_data The entry form data.
1353 * @since 2.0.0
1354 * @return array<string, array<mixed>>
1355 */
1356 private function parse_form_fields( $form_content, $entry_data = [] ) {
1357 if ( empty( $form_content ) ) {
1358 return [];
1359 }
1360
1361 // Parse blocks from form content.
1362 $blocks = parse_blocks( $form_content );
1363 if ( empty( $blocks ) ) {
1364 return [];
1365 }
1366
1367 // Get registered SureForms block attributes.
1368 $registry = \WP_Block_Type_Registry::get_instance();
1369 $registered_blocks = $registry->get_all_registered();
1370
1371 $sureforms_blocks = [];
1372 foreach ( $registered_blocks as $block_name => $block_type ) {
1373 if ( strpos( $block_name, 'srfm/' ) === 0 && is_array( $block_type->attributes ) ) {
1374 $block_key = str_replace( 'srfm/', '', $block_name );
1375 $sureforms_blocks[ $block_key ] = $block_type->attributes;
1376 }
1377 }
1378
1379 $form_fields = [];
1380 $this->extract_form_fields( $blocks, $sureforms_blocks, $form_fields, $entry_data, false );
1381
1382 return $form_fields;
1383 }
1384
1385 /**
1386 * Get endpoints
1387 *
1388 * @since 0.0.7
1389 * @return array<array<mixed>>
1390 */
1391 private function get_endpoints() {
1392 /*
1393 * @internal This filter is used to add custom endpoints.
1394 * @since 1.2.0
1395 * @param array<array<mixed>> $endpoints Endpoints.
1396 */
1397 return apply_filters(
1398 'srfm_rest_api_endpoints',
1399 [
1400 'generate-form' => [
1401 'methods' => 'POST',
1402 'callback' => [ AI_Form_Builder::get_instance(), 'generate_ai_form' ],
1403 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1404 'args' => [
1405 'use_system_message' => [
1406 'sanitize_callback' => [ $this, 'sanitize_boolean_field' ],
1407 ],
1408 ],
1409 ],
1410 // This route is used to map the AI response to SureForms fields markup.
1411 'map-fields' => [
1412 'methods' => 'POST',
1413 'callback' => [ Field_Mapping::get_instance(), 'generate_gutenberg_fields_from_questions' ],
1414 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1415 ],
1416 // Recreate the entries table when it has gone missing. The repair is
1417 // idempotent (CREATE TABLE IF NOT EXISTS) so a double-click is safe.
1418 'database/repair-entries-table' => [
1419 'methods' => 'POST',
1420 /**
1421 * Resolved at dispatch, not while the route table is built:
1422 * get_endpoints() runs on rest_api_init for every REST request,
1423 * and Admin is only constructed under is_admin(). Naming the
1424 * instance here would run Admin's constructor on the front-end
1425 * submit path too.
1426 *
1427 * @param \WP_REST_Request<array<string,mixed>> $request Request.
1428 * @return \WP_REST_Response|\WP_Error
1429 */
1430 'callback' => static function ( $request ) {
1431 $nonce = Helper::get_string_value( $request->get_header( 'X-WP-Nonce' ) );
1432
1433 if ( ! wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ) ) {
1434 return new \WP_Error(
1435 'rest_cookie_invalid_nonce',
1436 __( 'Security verification failed. Please refresh the page and try again.', 'sureforms' ),
1437 [ 'status' => 403 ]
1438 );
1439 }
1440
1441 // @phpstan-ignore-next-line -- PHPStan resolves SRFM\Admin\Admin via tests/php/stubs/srfm-stubs.php (admin/ is outside its `paths`) and that generated stub predates this method. Real location: admin/admin.php.
1442 $repaired = \SRFM\Admin\Admin::get_instance()->do_database_repair();
1443
1444 if ( ! $repaired ) {
1445 return new \WP_Error(
1446 'srfm_database_repair_failed',
1447 __( 'SureForms could not finish updating the database. Your hosting may not allow SureForms to create database tables — please contact your hosting provider or SureForms support.', 'sureforms' ),
1448 [ 'status' => 500 ]
1449 );
1450 }
1451
1452 return new \WP_REST_Response( [ 'success' => true ], 200 );
1453 },
1454 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1455 ],
1456 // Record a "Finish setting up" card CTA click for a form (#3031).
1457 // Per-form capability is re-checked in the handler.
1458 'dismiss-form-setup-card' => [
1459 'methods' => 'POST',
1460 /**
1461 * Resolve Admin at dispatch rather than while the route table is
1462 * built. get_endpoints() runs on rest_api_init for *every* REST
1463 * request, and plugin-loader.php only constructs Admin under
1464 * is_admin() — which REST dispatch is not. Naming the instance
1465 * here would therefore run Admin's constructor (40 admin hook
1466 * registrations, an option read, the notices library, and the
1467 * wpforms_current_user_can filter) on the front-end
1468 * submit-form path too.
1469 *
1470 * @param \WP_REST_Request<array<string,mixed>> $request Request.
1471 * @return \WP_REST_Response|\WP_Error
1472 */
1473 'callback' => static function ( $request ) {
1474 // @phpstan-ignore-next-line -- PHPStan resolves SRFM\Admin\Admin via tests/php/stubs/srfm-stubs.php (admin/ is outside its `paths`) and that generated stub predates this method. Real location: admin/admin.php:470.
1475 return \SRFM\Admin\Admin::get_instance()->dismiss_form_setup_card( $request );
1476 },
1477 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1478 'args' => [
1479 'form_id' => [
1480 'required' => true,
1481 'sanitize_callback' => 'absint',
1482 ],
1483 'action' => [
1484 'required' => true,
1485 'type' => 'string',
1486 'enum' => [ 'edit_form', 'edit_thankyou', 'set_up_email', 'view_form' ],
1487 // Core only enforces `enum` via the default arg sanitizer, which
1488 // is skipped once a sanitize_callback is set — so pair it with an
1489 // explicit validate_callback, matching this file's other routes.
1490 'validate_callback' => 'rest_validate_request_arg',
1491 'sanitize_callback' => 'sanitize_text_field',
1492 ],
1493 ],
1494 ],
1495 // This route is used to initiate auth process when user tries to authenticate on billing portal.
1496 'initiate-auth' => [
1497 'methods' => 'GET',
1498 'callback' => [ AI_Auth::get_instance(), 'get_auth_url' ],
1499 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1500 ],
1501 // This route is to used to decrypt the access key and save it in the database.
1502 'handle-access-key' => [
1503 'methods' => 'POST',
1504 'callback' => [ AI_Auth::get_instance(), 'handle_access_key' ],
1505 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1506 ],
1507 // Public route: returns the visitor's detected country code. Called
1508 // per-visitor from the phone field so auto-country detection works
1509 // on full-page-cached sites (the value isn't baked into cached HTML).
1510 'geo-country' => [
1511 'methods' => 'GET',
1512 'callback' => [ $this, 'get_geo_country' ],
1513 'permission_callback' => '__return_true',
1514 ],
1515 // This route is to get the form submissions for the last 30 days.
1516 'entries-chart-data' => [
1517 'methods' => 'GET',
1518 'callback' => [ $this, 'get_entries_chart_data' ],
1519 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1520 ],
1521 // This route is to get all forms data.
1522 'form-data' => [
1523 'methods' => 'GET',
1524 'callback' => [ $this, 'get_form_data' ],
1525 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1526 ],
1527 // Page search endpoint for async admin dropdowns.
1528 'pages/search' => [
1529 'methods' => 'GET',
1530 'callback' => [ $this, 'search_pages' ],
1531 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1532 'args' => [
1533 'search' => [
1534 'sanitize_callback' => 'sanitize_text_field',
1535 'default' => '',
1536 ],
1537 'page' => [
1538 'sanitize_callback' => 'absint',
1539 'default' => 1,
1540 'validate_callback' => static function ( $value ) {
1541 return is_numeric( $value ) && (int) $value >= 1;
1542 },
1543 ],
1544 'per_page' => [
1545 'sanitize_callback' => 'absint',
1546 'default' => 20,
1547 'validate_callback' => static function ( $value ) {
1548 return is_numeric( $value ) && (int) $value >= 1 && (int) $value <= 50;
1549 },
1550 ],
1551 'selected_url' => [
1552 'sanitize_callback' => 'esc_url_raw',
1553 'default' => '',
1554 'validate_callback' => static function ( $value ) {
1555 return empty( $value ) || false !== filter_var( $value, FILTER_VALIDATE_URL );
1556 },
1557 ],
1558 'selected_urls' => [
1559 'default' => [],
1560 'sanitize_callback' => static function( $value ) {
1561 if ( is_array( $value ) ) {
1562 return array_values( array_filter( array_map( 'esc_url_raw', $value ) ) );
1563 }
1564 if ( is_string( $value ) ) {
1565 return array_values(
1566 array_filter(
1567 array_map(
1568 'esc_url_raw',
1569 array_map( 'trim', explode( ',', $value ) )
1570 )
1571 )
1572 );
1573 }
1574 return [];
1575 },
1576 ],
1577 ],
1578 ],
1579 // Onboarding endpoints.
1580 'onboarding/set-status' => [
1581 'methods' => 'POST',
1582 'callback' => [ $this, 'set_onboarding_status' ],
1583 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1584 ],
1585 'onboarding/get-status' => [
1586 'methods' => 'GET',
1587 'callback' => [ $this, 'get_onboarding_status' ],
1588 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1589 ],
1590 'onboarding/user-details' => [
1591 'methods' => 'POST',
1592 'callback' => [ $this, 'save_onboarding_user_details' ],
1593 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1594 'args' => [
1595 'first_name' => [
1596 'required' => true,
1597 'sanitize_callback' => 'sanitize_text_field',
1598 'validate_callback' => static function( $value ) {
1599 return is_string( $value ) && '' !== trim( $value );
1600 },
1601 ],
1602 'last_name' => [
1603 'required' => false,
1604 'sanitize_callback' => 'sanitize_text_field',
1605 ],
1606 'email' => [
1607 'required' => true,
1608 'sanitize_callback' => 'sanitize_email',
1609 'validate_callback' => static function( $value ) {
1610 return is_string( $value ) && is_email( $value );
1611 },
1612 ],
1613 ],
1614 ],
1615 // Plugin status endpoint.
1616 'plugin-status' => [
1617 'methods' => 'GET',
1618 'callback' => [ $this, 'get_plugin_status' ],
1619 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1620 'args' => [
1621 'plugin' => [
1622 'required' => true,
1623 'sanitize_callback' => 'sanitize_text_field',
1624 ],
1625 ],
1626 ],
1627 // Entries endpoints.
1628 'entries/list' => [
1629 'methods' => 'GET',
1630 'callback' => [ $this, 'get_entries_list' ],
1631 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1632 'args' => [
1633 'form_id' => [
1634 'sanitize_callback' => 'absint',
1635 'default' => 0,
1636 ],
1637 'status' => [
1638 'sanitize_callback' => 'sanitize_text_field',
1639 'default' => 'all',
1640 ],
1641 'search' => [
1642 'sanitize_callback' => 'sanitize_text_field',
1643 'default' => '',
1644 ],
1645 'date_from' => [
1646 'sanitize_callback' => 'sanitize_text_field',
1647 'default' => '',
1648 ],
1649 'date_to' => [
1650 'sanitize_callback' => 'sanitize_text_field',
1651 'default' => '',
1652 ],
1653 'orderby' => [
1654 'type' => 'string',
1655 'sanitize_callback' => 'sanitize_text_field',
1656 'default' => 'created_at',
1657 'enum' => [ 'ID', 'id', 'form_id', 'user_id', 'status', 'type', 'created_at', 'updated_at' ],
1658 ],
1659 'order' => [
1660 'type' => 'string',
1661 'sanitize_callback' => 'sanitize_text_field',
1662 'default' => 'DESC',
1663 'enum' => [ 'ASC', 'DESC' ],
1664 ],
1665 'per_page' => [
1666 'sanitize_callback' => 'absint',
1667 'default' => 20,
1668 ],
1669 'page' => [
1670 'sanitize_callback' => 'absint',
1671 'default' => 1,
1672 ],
1673 ],
1674 ],
1675 'entries/read-status' => [
1676 'methods' => 'POST',
1677 'callback' => [ $this, 'update_entries_read_status' ],
1678 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1679 'args' => [
1680 'entry_ids' => [
1681 'required' => true,
1682 'sanitize_callback' => [ $this, 'sanitize_entry_ids' ],
1683 ],
1684 'action' => [
1685 'required' => true,
1686 'sanitize_callback' => 'sanitize_text_field',
1687 'validate_callback' => [ $this, 'validate_read_action' ],
1688 ],
1689 ],
1690 ],
1691 'entries/trash' => [
1692 'methods' => 'POST',
1693 'callback' => [ $this, 'update_entries_trash_status' ],
1694 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1695 'args' => [
1696 'entry_ids' => [
1697 'required' => true,
1698 'sanitize_callback' => [ $this, 'sanitize_entry_ids' ],
1699 ],
1700 'action' => [
1701 'required' => true,
1702 'sanitize_callback' => 'sanitize_text_field',
1703 'validate_callback' => [ $this, 'validate_trash_action' ],
1704 ],
1705 ],
1706 ],
1707 'entries/delete' => [
1708 'methods' => 'POST',
1709 'callback' => [ $this, 'delete_entries' ],
1710 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1711 'args' => [
1712 'entry_ids' => [
1713 'required' => true,
1714 'sanitize_callback' => [ $this, 'sanitize_entry_ids' ],
1715 ],
1716 ],
1717 ],
1718 'entries/export' => [
1719 'methods' => 'POST',
1720 'callback' => [ $this, 'export_entries' ],
1721 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1722 'args' => [
1723 'entry_ids' => [
1724 'sanitize_callback' => [ $this, 'sanitize_entry_ids' ],
1725 'default' => [],
1726 ],
1727 'form_id' => [
1728 'sanitize_callback' => 'absint',
1729 'default' => 0,
1730 ],
1731 'status' => [
1732 'sanitize_callback' => 'sanitize_text_field',
1733 'default' => 'all',
1734 ],
1735 'search' => [
1736 'sanitize_callback' => 'sanitize_text_field',
1737 'default' => '',
1738 ],
1739 'date_from' => [
1740 'sanitize_callback' => 'sanitize_text_field',
1741 'default' => '',
1742 ],
1743 'date_to' => [
1744 'sanitize_callback' => 'sanitize_text_field',
1745 'default' => '',
1746 ],
1747 ],
1748 ],
1749 // Get Single Entry Form Data.
1750 'entry/(?P<id>\d+)/details' => [
1751 'methods' => 'GET',
1752 'callback' => [ $this, 'get_entry_details' ],
1753 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1754 'args' => [
1755 'id' => [
1756 'required' => true,
1757 'sanitize_callback' => 'absint',
1758 ],
1759 ],
1760 ],
1761 // Get Single Entry Logs.
1762 'entry/(?P<id>\d+)/logs' => [
1763 'methods' => 'GET',
1764 'callback' => [ $this, 'get_entry_logs' ],
1765 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1766 'args' => [
1767 'id' => [
1768 'required' => true,
1769 'sanitize_callback' => 'absint',
1770 ],
1771 'per_page' => [
1772 'sanitize_callback' => 'absint',
1773 'default' => 3,
1774 ],
1775 'page' => [
1776 'sanitize_callback' => 'absint',
1777 'default' => 1,
1778 ],
1779 ],
1780 ],
1781 // Forms listing endpoint.
1782 'forms' => [
1783 'methods' => 'GET',
1784 'callback' => [ Forms_Data::get_instance(), 'get_forms_list' ],
1785 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1786 'args' => [
1787 'page' => [
1788 'type' => 'integer',
1789 'default' => 1,
1790 'minimum' => 1,
1791 ],
1792 'per_page' => [
1793 'type' => 'integer',
1794 'minimum' => 1,
1795 'maximum' => 100,
1796 ],
1797 'search' => [
1798 'type' => 'string',
1799 ],
1800 'status' => [
1801 'type' => 'string',
1802 'enum' => [ 'publish', 'draft', 'trash', 'any' ],
1803 'default' => 'publish',
1804 ],
1805 'orderby' => [
1806 'type' => 'string',
1807 'default' => 'date',
1808 'enum' => [ 'date', 'id', 'title', 'modified', 'views', 'conversion_rate' ],
1809 ],
1810 'order' => [
1811 'type' => 'string',
1812 'default' => 'desc',
1813 'enum' => [ 'asc', 'desc' ],
1814 ],
1815 'date_from' => [
1816 'type' => 'string',
1817 'format' => 'date',
1818 'sanitize_callback' => 'sanitize_text_field',
1819 'validate_callback' => static function( $value ) {
1820 if ( empty( $value ) ) {
1821 return true;
1822 }
1823 return (bool) strtotime( $value );
1824 },
1825 ],
1826 'date_to' => [
1827 'type' => 'string',
1828 'format' => 'date',
1829 'sanitize_callback' => 'sanitize_text_field',
1830 'validate_callback' => static function( $value ) {
1831 if ( empty( $value ) ) {
1832 return true;
1833 }
1834 return (bool) strtotime( $value );
1835 },
1836 ],
1837 ],
1838 ],
1839 // Export forms endpoint.
1840 'forms/export' => [
1841 'methods' => 'POST',
1842 'callback' => [ Export::get_instance(), 'handle_export_form_rest' ],
1843 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1844 'args' => [
1845 'post_ids' => [
1846 'required' => true,
1847 'type' => [ 'array', 'string' ],
1848 'sanitize_callback' => static function( $value ) {
1849 if ( is_array( $value ) ) {
1850 return array_map( 'intval', $value );
1851 }
1852 return sanitize_text_field( $value );
1853 },
1854 'validate_callback' => static function( $value ) {
1855 if ( is_array( $value ) ) {
1856 return ! empty( $value );
1857 }
1858 return ! empty( trim( $value ) );
1859 },
1860 ],
1861 ],
1862 ],
1863 // Import forms endpoint.
1864 'forms/import' => [
1865 'methods' => 'POST',
1866 'callback' => [ Export::get_instance(), 'handle_import_form_rest' ],
1867 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1868 'args' => [
1869 'forms_data' => [
1870 'required' => true,
1871 'type' => 'array',
1872 'validate_callback' => static function( $value ) {
1873 return is_array( $value ) && ! empty( $value );
1874 },
1875 ],
1876 'default_status' => [
1877 'required' => false,
1878 'type' => 'string',
1879 'default' => 'draft',
1880 'enum' => [ 'draft', 'publish', 'private' ],
1881 'sanitize_callback' => 'sanitize_text_field',
1882 ],
1883 ],
1884 ],
1885 // Form lifecycle management endpoint (trash/restore/delete/draft).
1886 'forms/manage' => [
1887 'methods' => 'POST',
1888 'callback' => [ $this, 'manage_form_lifecycle' ],
1889 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1890 'args' => [
1891 'form_ids' => [
1892 'required' => true,
1893 'type' => [ 'array', 'integer' ],
1894 'sanitize_callback' => static function( $value ) {
1895 if ( is_array( $value ) ) {
1896 return array_map( 'intval', $value );
1897 }
1898 return [ intval( $value ) ];
1899 },
1900 'validate_callback' => static function( $value ) {
1901 if ( is_array( $value ) ) {
1902 return ! empty( $value );
1903 }
1904 return $value > 0;
1905 },
1906 ],
1907 'action' => [
1908 'required' => true,
1909 'type' => 'string',
1910 'enum' => [ 'trash', 'restore', 'delete', 'draft' ],
1911 'sanitize_callback' => 'sanitize_text_field',
1912 ],
1913 ],
1914 ],
1915 // Form duplication endpoint.
1916 'forms/duplicate' => [
1917 'methods' => 'POST',
1918 'callback' => [ Duplicate_Form::get_instance(), 'handle_duplicate_form_rest' ],
1919 'permission_callback' => [ Helper::class, 'get_items_permissions_check' ],
1920 'args' => [
1921 'form_id' => [
1922 'required' => true,
1923 'type' => 'integer',
1924 'sanitize_callback' => 'absint',
1925 'validate_callback' => static function( $value ) {
1926 return $value > 0;
1927 },
1928 ],
1929 'title_suffix' => [
1930 'required' => false,
1931 'type' => 'string',
1932 'default' => __( ' (Copy)', 'sureforms' ),
1933 'sanitize_callback' => 'sanitize_text_field',
1934 ],
1935 ],
1936 ],
1937 ]
1938 );
1939 }
1940 }
1941