PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.0
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.0
4.0.6 4.0.5 4.0.4 4.0.3 4.0.2 4.0.1 4.0.0 trunk 1.0.0 2.0.0 2.0.1 2.0.2 2.0.3 3.0 3.0.1 3.0.2 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.2 3.1.3 3.2.0 3.2.1 3.2.2 3.2.4 3.2.5 3.2.7 3.2.8 3.2.9 3.3.0 3.3.1 3.3.2 3.4.0 3.4.1 3.4.2 3.4.5 3.4.6 3.5.0 3.5.1 3.5.2 3.5.3 3.5.4 3.5.6 3.5.7 3.5.8 3.5.9 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1
superb-blocks / src / gutenberg / form / class-form-controller.php
superb-blocks / src / gutenberg / form Last commit date
class-form-access-control.php 1 month ago class-form-captcha-handler.php 1 month ago class-form-controller.php 1 month ago class-form-email-config-check.php 1 month ago class-form-email-handler.php 1 month ago class-form-encryption.php 1 month ago class-form-exporter.php 1 month ago class-form-field-validator.php 1 month ago class-form-file-handler.php 1 month ago class-form-google-auth.php 1 month ago class-form-integration-handler.php 1 month ago class-form-math-parser.php 1 month ago class-form-permissions.php 1 month ago class-form-registry.php 1 month ago class-form-settings.php 1 month ago class-form-submission-cpt.php 1 month ago class-form-submission-handler.php 1 month ago
class-form-controller.php
1930 lines
1 <?php
2
3 namespace SuperbAddons\Gutenberg\Form;
4
5 defined('ABSPATH') || exit();
6
7 use SuperbAddons\Config\Capabilities;
8 use SuperbAddons\Data\Controllers\RestController;
9
10 class FormController
11 {
12 const SUBMIT_ROUTE = '/form/submit';
13 const NONCE_ROUTE = '/form/nonce';
14 const SUBMISSIONS_ROUTE = '/form/submissions';
15 const SUBMISSIONS_ITEM_ROUTE = '/form/submissions/(?P<id>\d+)';
16 const SUBMISSIONS_BULK_DELETE_ROUTE = '/form/submissions/bulk';
17 const SUBMISSIONS_COUNT_ROUTE = '/form/submissions/count';
18 const SUBMISSIONS_MARK_READ_ROUTE = '/form/submissions/(?P<id>\d+)/read';
19 const SUBMISSIONS_MARK_UNREAD_ROUTE = '/form/submissions/(?P<id>\d+)/unread';
20 const SUBMISSIONS_BULK_STATUS_ROUTE = '/form/submissions/bulk-status';
21 const SUBMISSIONS_FORMS_ROUTE = '/form/submissions/forms';
22 const FORM_DELETE_ROUTE = '/form/(?P<form_id>[a-zA-Z0-9_-]+)';
23 const MAILCHIMP_LISTS_ROUTE = '/form/integrations/mailchimp/lists';
24 const BREVO_LISTS_ROUTE = '/form/integrations/brevo/lists';
25 const CAPTCHA_STATUS_ROUTE = '/form/captcha/status';
26 const SUBMISSIONS_STAR_ROUTE = '/form/submissions/(?P<id>\d+)/star';
27 const SUBMISSIONS_UNSTAR_ROUTE = '/form/submissions/(?P<id>\d+)/unstar';
28 const SUBMISSIONS_BULK_STAR_ROUTE = '/form/submissions/bulk-star';
29 const SUBMISSIONS_RESEND_EMAIL_ROUTE = '/form/submissions/(?P<id>\d+)/resend-email';
30 const EXPORT_ROUTE = '/form/(?P<form_id>[a-zA-Z0-9_-]+)/export';
31 const FILE_DOWNLOAD_ROUTE = '/form/submissions/(?P<id>\d+)/file/(?P<field_id>[a-zA-Z0-9_-]+)/(?P<index>\d+)';
32 const NONCE_ACTION = 'superb_form_submit';
33
34 const SUBMISSIONS_NOT_SPAM_ROUTE = '/form/submissions/(?P<id>\d+)/not-spam';
35 const SUBMISSIONS_SPAM_COUNT_ROUTE = '/form/(?P<form_id>[a-zA-Z0-9_-]+)/spam-count';
36 const RETRY_INTEGRATION_ROUTE = '/form/submissions/(?P<id>\d+)/retry-integration';
37
38 // Phase 3: Notes
39 const SUBMISSIONS_NOTES_ROUTE = '/form/submissions/(?P<id>\d+)/notes';
40 const SUBMISSIONS_NOTES_DELETE_ROUTE = '/form/submissions/(?P<id>\d+)/notes/(?P<index>\d+)';
41
42 // Phase 3: Field preferences
43 const FIELDS_SAVE_ROUTE = '/form/fields';
44 const FIELDS_GET_ROUTE = '/form/fields/(?P<form_id>[a-zA-Z0-9_-]+)';
45
46 // Integrations: Webhook, Google Sheets, Slack
47 const WEBHOOK_TEST_ROUTE = '/form/webhook/test';
48 const WEBHOOK_SECRET_ROUTE = '/form/(?P<form_id>[a-zA-Z0-9_-]+)/webhook-secret';
49 const GOOGLE_SHEETS_STATUS_ROUTE = '/form/integrations/google-sheets/status';
50 const GOOGLE_SHEETS_TEST_ROUTE = '/form/integrations/google-sheets/test';
51 const SLACK_TEST_ROUTE = '/form/integrations/slack/test';
52
53 public static function Initialize()
54 {
55 FormSubmissionCPT::Initialize();
56 FormRegistry::Initialize();
57 FormAccessControl::Initialize();
58
59 // Schedule spam auto-purge cron
60 FormSubmissionHandler::ScheduleSpamPurge();
61 add_action(FormSubmissionHandler::SPAM_PURGE_HOOK, array('SuperbAddons\Gutenberg\Form\FormSubmissionHandler', 'PurgeOldSpam'));
62
63 // Schedule data retention auto-purge cron
64 FormSubmissionHandler::ScheduleRetentionPurge();
65 add_action(FormSubmissionHandler::RETENTION_PURGE_HOOK, array('SuperbAddons\Gutenberg\Form\FormSubmissionHandler', 'PurgeOldSubmissions'));
66
67 RestController::AddRoute(self::NONCE_ROUTE, array(
68 'methods' => 'GET',
69 'permission_callback' => '__return_true',
70 'callback' => array(__CLASS__, 'NonceCallback'),
71 ));
72
73 RestController::AddRoute(self::SUBMIT_ROUTE, array(
74 'methods' => 'POST',
75 'permission_callback' => '__return_true',
76 'callback' => array(__CLASS__, 'SubmitCallback'),
77 ));
78
79 // View permission: list submissions, view forms, counts, fields, file downloads
80 RestController::AddRoute(self::SUBMISSIONS_ROUTE, array(
81 'methods' => 'GET',
82 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
83 'callback' => array(__CLASS__, 'GetSubmissionsCallback'),
84 ));
85
86 RestController::AddRoute(self::SUBMISSIONS_COUNT_ROUTE, array(
87 'methods' => 'GET',
88 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
89 'callback' => array(__CLASS__, 'GetSubmissionsCountCallback'),
90 ));
91
92 RestController::AddRoute(self::SUBMISSIONS_FORMS_ROUTE, array(
93 'methods' => 'GET',
94 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
95 'callback' => array(__CLASS__, 'GetSubmissionsFormsCallback'),
96 ));
97
98 RestController::AddRoute(self::SUBMISSIONS_MARK_READ_ROUTE, array(
99 'methods' => 'POST',
100 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
101 'callback' => array(__CLASS__, 'MarkSubmissionReadCallback'),
102 ));
103
104 RestController::AddRoute(self::SUBMISSIONS_MARK_UNREAD_ROUTE, array(
105 'methods' => 'POST',
106 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
107 'callback' => array(__CLASS__, 'MarkSubmissionUnreadCallback'),
108 ));
109
110 RestController::AddRoute(self::SUBMISSIONS_BULK_STATUS_ROUTE, array(
111 'methods' => 'POST',
112 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
113 'callback' => array(__CLASS__, 'BulkUpdateStatusCallback'),
114 ));
115
116 RestController::AddRoute(self::FILE_DOWNLOAD_ROUTE, array(
117 'methods' => 'GET',
118 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
119 'callback' => array(__CLASS__, 'ServeFileCallback'),
120 ));
121
122 RestController::AddRoute(self::SUBMISSIONS_RESEND_EMAIL_ROUTE, array(
123 'methods' => 'POST',
124 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
125 'callback' => array(__CLASS__, 'ResendEmailCallback'),
126 ));
127
128 // Star/unstar: anyone with view permission
129 RestController::AddRoute(self::SUBMISSIONS_STAR_ROUTE, array(
130 'methods' => 'POST',
131 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
132 'callback' => array(__CLASS__, 'StarSubmissionCallback'),
133 ));
134
135 RestController::AddRoute(self::SUBMISSIONS_UNSTAR_ROUTE, array(
136 'methods' => 'POST',
137 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
138 'callback' => array(__CLASS__, 'UnstarSubmissionCallback'),
139 ));
140
141 RestController::AddRoute(self::SUBMISSIONS_BULK_STAR_ROUTE, array(
142 'methods' => 'POST',
143 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
144 'callback' => array(__CLASS__, 'BulkStarCallback'),
145 ));
146
147 // Delete permission
148 RestController::AddRoute(self::SUBMISSIONS_ITEM_ROUTE, array(
149 'methods' => 'DELETE',
150 'permission_callback' => array(__CLASS__, 'DeletePermissionCheck'),
151 'callback' => array(__CLASS__, 'DeleteSubmissionCallback'),
152 ));
153
154 RestController::AddRoute(self::SUBMISSIONS_BULK_DELETE_ROUTE, array(
155 'methods' => 'DELETE',
156 'permission_callback' => array(__CLASS__, 'DeletePermissionCheck'),
157 'callback' => array(__CLASS__, 'BulkDeleteSubmissionsCallback'),
158 ));
159
160 // Export permission
161 RestController::AddRoute(self::EXPORT_ROUTE, array(
162 'methods' => 'GET',
163 'permission_callback' => array(__CLASS__, 'ExportPermissionCheck'),
164 'callback' => array(__CLASS__, 'ExportCallback'),
165 ));
166
167 // Spam permission
168 RestController::AddRoute(self::SUBMISSIONS_NOT_SPAM_ROUTE, array(
169 'methods' => 'POST',
170 'permission_callback' => array(__CLASS__, 'SpamPermissionCheck'),
171 'callback' => array(__CLASS__, 'NotSpamCallback'),
172 ));
173
174 RestController::AddRoute(self::SUBMISSIONS_SPAM_COUNT_ROUTE, array(
175 'methods' => 'GET',
176 'permission_callback' => array(__CLASS__, 'SpamPermissionCheck'),
177 'callback' => array(__CLASS__, 'GetSpamCountCallback'),
178 ));
179
180 // Notes permission
181 RestController::AddRoute(self::SUBMISSIONS_NOTES_ROUTE, array(
182 array(
183 'methods' => 'GET',
184 'permission_callback' => array(__CLASS__, 'NotesPermissionCheck'),
185 'callback' => array(__CLASS__, 'GetNotesCallback'),
186 ),
187 array(
188 'methods' => 'POST',
189 'permission_callback' => array(__CLASS__, 'NotesPermissionCheck'),
190 'callback' => array(__CLASS__, 'AddNoteCallback'),
191 ),
192 ));
193
194 RestController::AddRoute(self::SUBMISSIONS_NOTES_DELETE_ROUTE, array(
195 'methods' => 'DELETE',
196 'permission_callback' => array(__CLASS__, 'NotesPermissionCheck'),
197 'callback' => array(__CLASS__, 'DeleteNoteCallback'),
198 ));
199
200 // Admin-only: form deletion, integrations, captcha status, retry integration
201 RestController::AddRoute(self::FORM_DELETE_ROUTE, array(
202 'methods' => 'DELETE',
203 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
204 'callback' => array(__CLASS__, 'DeleteFormCallback'),
205 ));
206
207 RestController::AddRoute(self::MAILCHIMP_LISTS_ROUTE, array(
208 'methods' => 'GET',
209 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
210 'callback' => array(__CLASS__, 'GetMailchimpListsCallback'),
211 ));
212
213 RestController::AddRoute(self::BREVO_LISTS_ROUTE, array(
214 'methods' => 'GET',
215 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
216 'callback' => array(__CLASS__, 'GetBrevoListsCallback'),
217 ));
218
219 RestController::AddRoute(self::CAPTCHA_STATUS_ROUTE, array(
220 'methods' => 'GET',
221 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
222 'callback' => array(__CLASS__, 'GetCaptchaStatusCallback'),
223 ));
224
225 RestController::AddRoute(self::RETRY_INTEGRATION_ROUTE, array(
226 'methods' => 'POST',
227 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
228 'callback' => array(__CLASS__, 'RetryIntegrationCallback'),
229 ));
230
231 RestController::AddRoute(self::WEBHOOK_TEST_ROUTE, array(
232 'methods' => 'POST',
233 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
234 'callback' => array(__CLASS__, 'WebhookTestCallback'),
235 ));
236
237 RestController::AddRoute(self::GOOGLE_SHEETS_STATUS_ROUTE, array(
238 'methods' => 'GET',
239 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
240 'callback' => array(__CLASS__, 'GoogleSheetsStatusCallback'),
241 ));
242
243 RestController::AddRoute(self::GOOGLE_SHEETS_TEST_ROUTE, array(
244 'methods' => 'POST',
245 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
246 'callback' => array(__CLASS__, 'GoogleSheetsTestCallback'),
247 ));
248
249 RestController::AddRoute(self::WEBHOOK_SECRET_ROUTE, array(
250 'methods' => array('GET', 'POST', 'DELETE'),
251 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
252 'callback' => array(__CLASS__, 'WebhookSecretCallback'),
253 ));
254
255 RestController::AddRoute(self::SLACK_TEST_ROUTE, array(
256 'methods' => 'POST',
257 'permission_callback' => array(__CLASS__, 'AdminPermissionCheck'),
258 'callback' => array(__CLASS__, 'SlackTestCallback'),
259 ));
260
261 // Field preferences: anyone with view permission
262 RestController::AddRoute(self::FIELDS_SAVE_ROUTE, array(
263 'methods' => 'POST',
264 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
265 'callback' => array(__CLASS__, 'SaveFieldsCallback'),
266 ));
267
268 RestController::AddRoute(self::FIELDS_GET_ROUTE, array(
269 'methods' => 'GET',
270 'permission_callback' => array(__CLASS__, 'ViewPermissionCheck'),
271 'callback' => array(__CLASS__, 'GetFieldsCallback'),
272 ));
273 }
274
275 public static function AdminPermissionCheck()
276 {
277 return current_user_can('manage_options');
278 }
279
280 public static function ViewPermissionCheck()
281 {
282 return FormPermissions::Can('view');
283 }
284
285 public static function DeletePermissionCheck()
286 {
287 return FormPermissions::Can('delete');
288 }
289
290 public static function ExportPermissionCheck()
291 {
292 return FormPermissions::Can('export');
293 }
294
295 public static function SpamPermissionCheck()
296 {
297 return FormPermissions::Can('spam');
298 }
299
300 public static function NotesPermissionCheck()
301 {
302 return FormPermissions::Can('notes');
303 }
304
305 /**
306 * Return a fresh nonce for form submission.
307 * This solves cached pages where inline nonces expire.
308 */
309 public static function NonceCallback()
310 {
311 return rest_ensure_response(array(
312 'nonce' => wp_create_nonce(self::NONCE_ACTION),
313 ));
314 }
315
316 /**
317 * Handle form submission.
318 */
319 public static function SubmitCallback($request)
320 {
321 // Detect request format (multipart for file uploads, JSON for text-only)
322 $content_type = $request->get_content_type();
323 $is_multipart = $content_type && isset($content_type['value']) && strpos($content_type['value'], 'multipart/form-data') !== false;
324
325 if ($is_multipart) {
326 $params = $request->get_body_params();
327 } else {
328 $params = $request->get_json_params();
329 }
330 if (!is_array($params)) {
331 $params = array();
332 }
333
334 $form_id = isset($params['form_id']) ? sanitize_text_field($params['form_id']) : '';
335 $fields = isset($params['fields']) && is_array($params['fields']) ? $params['fields'] : array();
336 $captcha_token = isset($params['captcha_token']) ? sanitize_text_field($params['captcha_token']) : '';
337 // Accept both new (field_ref) and legacy (guard_ts) timing parameter names
338 $guard_ts = isset($params['field_ref']) ? sanitize_text_field($params['field_ref']) : '';
339 if (empty($guard_ts)) {
340 $guard_ts = isset($params['guard_ts']) ? sanitize_text_field($params['guard_ts']) : '';
341 }
342 $field_env = isset($params['field_env']) ? sanitize_text_field($params['field_env']) : '0';
343
344 // Verify nonce
345 $nonce = $request->get_header('X-Superb-Form-Nonce');
346 if (!wp_verify_nonce($nonce, self::NONCE_ACTION)) {
347 return new \WP_REST_Response(array(
348 'success' => false,
349 'message' => __('Security verification failed. Please refresh and try again.', 'superb-blocks'),
350 ), 403);
351 }
352
353 // Rate limiting — fixed 5-minute window per IP
354 $ip_hash = wp_hash(isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '');
355 $rate_key = 'spb_form_rate_' . $ip_hash;
356 $rate_data = get_transient($rate_key);
357 $rate_now = time();
358 if (is_array($rate_data) && isset($rate_data['count'], $rate_data['expires']) && intval($rate_data['expires']) > $rate_now) {
359 $rate_count = intval($rate_data['count']);
360 $rate_expires = intval($rate_data['expires']);
361 } else {
362 $rate_count = 0;
363 $rate_expires = $rate_now + 300;
364 }
365 if ($rate_count >= 10) {
366 return new \WP_REST_Response(array(
367 'success' => false,
368 'message' => __('Too many submissions. Please try again later.', 'superb-blocks'),
369 ), 429);
370 }
371 set_transient(
372 $rate_key,
373 array('count' => $rate_count + 1, 'expires' => $rate_expires),
374 max(1, $rate_expires - $rate_now)
375 );
376
377 // Read captcha type from server-side config (not client-supplied)
378 $form_data = self::GetFormConfig($form_id);
379 if ($form_data === null) {
380 return new \WP_REST_Response(array(
381 'success' => false,
382 'message' => __('Invalid form.', 'superb-blocks'),
383 ), 400);
384 }
385 $captcha_type = isset($form_data['captcha_type']) ? $form_data['captcha_type'] : 'honeypot';
386
387 // Server-side honeypot + timing validation
388 $store_spam = !empty($form_data['store_enabled']) && !empty($form_data['store_spam_enabled']);
389 if ($captcha_type === 'honeypot') {
390 $honeypot_key = isset($form_data['honeypot_key']) ? $form_data['honeypot_key'] : '';
391 if (!empty($honeypot_key)) {
392 $hp_value = isset($fields[$honeypot_key]) ? $fields[$honeypot_key] : '';
393 $hp_filled = is_array($hp_value) ? count($hp_value) > 0 : trim((string) $hp_value) !== '';
394 if ($hp_filled) {
395 FormSubmissionHandler::IncrementSpamCount($form_id);
396 if ($store_spam) {
397 FormSubmissionHandler::StoreSpam($form_id, $fields, 'honeypot');
398 }
399 return new \WP_REST_Response(array(
400 'success' => false,
401 'message' => __('Spam detected.', 'superb-blocks'),
402 ), 400);
403 }
404 }
405 // Timing check — reject submissions faster than 3 seconds.
406 // A non-numeric value means a malformed/forged guard; treat as spam.
407 if (!empty($guard_ts)) {
408 if (!ctype_digit($guard_ts)) {
409 FormSubmissionHandler::IncrementSpamCount($form_id);
410 if ($store_spam) {
411 FormSubmissionHandler::StoreSpam($form_id, $fields, 'bot_detection');
412 }
413 return new \WP_REST_Response(array(
414 'success' => false,
415 'message' => __('Spam detected.', 'superb-blocks'),
416 ), 400);
417 }
418 $elapsed = time() - intval($guard_ts);
419 if ($elapsed < 3) {
420 FormSubmissionHandler::IncrementSpamCount($form_id);
421 if ($store_spam) {
422 FormSubmissionHandler::StoreSpam($form_id, $fields, 'bot_detection');
423 }
424 return new \WP_REST_Response(array(
425 'success' => false,
426 'message' => __('Please wait a moment before submitting.', 'superb-blocks'),
427 ), 400);
428 }
429 }
430 // Reject automated/headless browsers (navigator.webdriver = true)
431 if ($field_env === '1') {
432 FormSubmissionHandler::IncrementSpamCount($form_id);
433 if ($store_spam) {
434 FormSubmissionHandler::StoreSpam($form_id, $fields, 'bot_detection');
435 }
436 return new \WP_REST_Response(array(
437 'success' => false,
438 'message' => __('Spam detected.', 'superb-blocks'),
439 ), 400);
440 }
441 }
442
443 // Verify captcha (third-party providers)
444 $captcha_result = FormCaptchaHandler::Verify($captcha_type, $captcha_token);
445 if ($captcha_result !== true) {
446 FormSubmissionHandler::IncrementSpamCount($form_id);
447 if ($store_spam) {
448 FormSubmissionHandler::StoreSpam($form_id, $fields, 'captcha');
449 }
450 return new \WP_REST_Response(array(
451 'success' => false,
452 'message' => is_string($captcha_result) && $captcha_result !== ''
453 ? $captcha_result
454 : __('Captcha verification failed. Please try again.', 'superb-blocks'),
455 ), 400);
456 }
457
458 // Validate submitted fields against server-side config
459 $form_fields = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
460 $validation_result = FormFieldValidator::Validate($fields, $form_fields);
461 $fields = $validation_result['fields'];
462
463 if (!empty($validation_result['errors'])) {
464 return new \WP_REST_Response(array(
465 'success' => false,
466 'message' => __('Please correct the errors below.', 'superb-blocks'),
467 'errors' => $validation_result['errors'],
468 ), 400);
469 }
470
471 // Type-aware sanitization
472 $sanitized_fields = array();
473 $field_type_lookup = array();
474 foreach ($form_fields as $fc) {
475 if (isset($fc['fieldId'])) {
476 $field_type_lookup[$fc['fieldId']] = isset($fc['fieldType']) ? $fc['fieldType'] : 'text';
477 }
478 }
479 foreach ($fields as $key => $value) {
480 $skey = sanitize_text_field($key);
481 $ftype = isset($field_type_lookup[$skey]) ? $field_type_lookup[$skey] : 'text';
482
483 if ($ftype === 'textarea') {
484 $sanitized_fields[$skey] = sanitize_textarea_field($value);
485 } elseif ($ftype === 'signature') {
486 // Signature stores a PNG data URL. sanitize_text_field would mangle the base64.
487 // Validation already ensures correct format and size in FormFieldValidator.
488 $prefix = 'data:image/png;base64,';
489 if (strpos($value, $prefix) === 0 && strlen($value) <= 500000) {
490 $sanitized_fields[$skey] = $value;
491 } else {
492 $sanitized_fields[$skey] = '';
493 }
494 } else {
495 $sanitized_fields[$skey] = sanitize_text_field($value);
496 }
497 }
498
499 // Recalculate calculated fields server-side (don't trust client values)
500 foreach ($form_fields as $fc) {
501 if (isset($fc['fieldType']) && $fc['fieldType'] === 'calculated' && isset($fc['fieldId'])) {
502 $calc_id = $fc['fieldId'];
503 $cs = isset($fc['calculatedSettings']) && is_array($fc['calculatedSettings'])
504 ? $fc['calculatedSettings']
505 : array();
506 $formula = isset($cs['formula']) ? $cs['formula'] : '';
507 $round_result = isset($cs['roundResult']) ? intval($cs['roundResult']) : -1;
508
509 if ($formula !== '') {
510 $result = FormMathParser::Evaluate($formula, $sanitized_fields, $round_result);
511 $sanitized_fields[$calc_id] = strval($result);
512 }
513 }
514 }
515
516 // Process file uploads
517 $file_data = array();
518 if (!empty($_FILES['files'])) {
519 $file_data = FormFileHandler::ProcessUploads($form_fields);
520 // Merge file metadata into sanitized fields for storage
521 foreach ($file_data as $fid => $ffiles) {
522 $sanitized_fields[$fid] = $ffiles;
523 }
524 }
525
526 if (empty($sanitized_fields)) {
527 return new \WP_REST_Response(array(
528 'success' => false,
529 'message' => __('No fields submitted.', 'superb-blocks'),
530 ), 400);
531 }
532
533 // Store submission if enabled
534 $submission_post_id = 0;
535 if (!empty($form_data['store_enabled'])) {
536 $storage_fields = $sanitized_fields;
537 // Encrypt sensitive fields before storage
538 foreach ($form_fields as $fc) {
539 $fid = isset($fc['fieldId']) ? $fc['fieldId'] : '';
540 if (!empty($fc['sensitive']) && $fid !== '' && isset($storage_fields[$fid]) && is_string($storage_fields[$fid])) {
541 $storage_fields[$fid] = FormEncryption::Encrypt($storage_fields[$fid]);
542 }
543 }
544 $submission_post_id = FormSubmissionHandler::Store($form_id, $storage_fields);
545 if ($submission_post_id === false) {
546 $submission_post_id = 0;
547 }
548 }
549
550 // Send admin notification email
551 if (!empty($form_data['email_enabled'])) {
552 $to = !empty($form_data['email_to']) ? $form_data['email_to'] : get_option('admin_email');
553 $valid_emails = array_filter(array_map('trim', explode(',', $to)), 'is_email');
554 if (!empty($valid_emails)) {
555 $form_data['email_to'] = implode(',', array_map('sanitize_email', $valid_emails));
556 FormEmailHandler::SendAdminNotification($form_data, $sanitized_fields, $submission_post_id);
557 }
558 }
559
560 // Send user confirmation
561 if (!empty($form_data['send_confirmation'])) {
562 FormEmailHandler::SendConfirmation($form_data, $sanitized_fields, $submission_post_id);
563 }
564
565 // Send to integrations and track status
566 self::ProcessIntegrations($form_data, $sanitized_fields, $submission_post_id);
567
568 // Premium hook
569 do_action('superbaddons_form_after_submit', $form_id, $sanitized_fields, $form_data);
570
571 $response = array(
572 'success' => true,
573 'message' => __('Form submitted successfully.', 'superb-blocks'),
574 );
575
576 // Include redirect URL from server-side config (not client-supplied)
577 $success_behavior = isset($form_data['success_behavior']) ? $form_data['success_behavior'] : 'message';
578 $redirect_url = isset($form_data['redirect_url']) ? $form_data['redirect_url'] : '';
579 if ($success_behavior === 'redirect' && !empty($redirect_url)) {
580 $response['redirect_url'] = esc_url($redirect_url);
581 }
582
583 return rest_ensure_response($response);
584 }
585
586 /**
587 * Get submissions for a form.
588 */
589 public static function GetSubmissionsCallback($request)
590 {
591 $form_id = isset($request['form_id']) ? sanitize_text_field($request['form_id']) : '';
592 $page = isset($request['page']) ? intval($request['page']) : 1;
593 $per_page = isset($request['per_page']) ? intval($request['per_page']) : 20;
594 $status = isset($request['status']) ? sanitize_text_field($request['status']) : '';
595 $starred = isset($request['starred']) ? sanitize_text_field($request['starred']) : '';
596 $search = isset($request['search']) ? sanitize_text_field($request['search']) : '';
597 $date_after = isset($request['date_after']) ? sanitize_text_field($request['date_after']) : '';
598 $date_before = isset($request['date_before']) ? sanitize_text_field($request['date_before']) : '';
599
600 // Cap per_page to prevent abuse
601 if ($per_page < 1) {
602 $per_page = 20;
603 }
604 if ($per_page > 100) {
605 $per_page = 100;
606 }
607
608 $result = FormSubmissionHandler::GetSubmissions($form_id, $page, $per_page, $status, $starred, $search, $date_after, $date_before);
609
610 // Include counts for filter tabs
611 if (!empty($form_id)) {
612 $counts = FormSubmissionHandler::GetCount($form_id);
613 $result['count_total'] = $counts['total'];
614 $result['count_new'] = $counts['new'];
615 $result['count_read'] = $counts['total'] - $counts['new'];
616 }
617
618 // Load form config once (kept as array so downstream !empty()/is_array() checks stay safe)
619 $attrs = array();
620 if (!empty($form_id)) {
621 $loaded = FormRegistry::GetConfig($form_id);
622 if (is_array($loaded)) {
623 $attrs = $loaded;
624 }
625 }
626
627 // Include field labels from form config
628 $field_labels = array();
629 if (!empty($attrs['formFields']) && is_array($attrs['formFields'])) {
630 foreach ($attrs['formFields'] as $field) {
631 if (isset($field['fieldId']) && isset($field['label'])) {
632 $field_labels[$field['fieldId']] = $field['label'];
633 }
634 }
635 }
636
637 $result['field_labels'] = $field_labels;
638
639 // Build sensitive field lookup and decrypt stored values
640 $form_fields_config = (!empty($attrs['formFields']) && is_array($attrs['formFields'])) ? $attrs['formFields'] : array();
641 $pending_delete = !empty($form_id) && empty($form_fields_config) && FormRegistry::IsPendingDelete($form_id);
642 $sensitive_fields = array();
643 foreach ($form_fields_config as $field) {
644 if (!empty($field['sensitive']) && !empty($field['fieldId'])) {
645 $sensitive_fields[] = $field['fieldId'];
646 }
647 }
648 // Pass 1 (pending_delete only): discover sensitive fields across ALL submissions
649 // by encryption prefix before we decrypt anything. Without this, a submission whose
650 // plaintext predates encryption would leak unmasked while later encrypted rows mask correctly.
651 if ($pending_delete) {
652 foreach ($result['submissions'] as $sub) {
653 if (empty($sub['fields']) || !is_array($sub['fields'])) {
654 continue;
655 }
656 foreach ($sub['fields'] as $fid => $value) {
657 if (FormEncryption::IsEncrypted($value) && !in_array($fid, $sensitive_fields, true)) {
658 $sensitive_fields[] = $fid;
659 }
660 }
661 }
662 }
663 // Pass 2: decrypt and mask using the complete sensitive_fields list
664 $can_view_sensitive = FormPermissions::Can('sensitive');
665 foreach ($result['submissions'] as &$sub) {
666 $sub['fields'] = self::DecryptSubmissionFields($form_fields_config, $sub['fields'], $pending_delete);
667 if (!$can_view_sensitive && !empty($sensitive_fields)) {
668 foreach ($sensitive_fields as $sfid) {
669 if (isset($sub['fields'][$sfid]) && is_string($sub['fields'][$sfid]) && $sub['fields'][$sfid] !== '') {
670 $sub['fields'][$sfid] = str_repeat("\xE2\x80\xA2", 8);
671 }
672 }
673 }
674 }
675 unset($sub);
676 $result['sensitive_fields'] = $sensitive_fields;
677 $result['can_view_sensitive'] = $can_view_sensitive;
678
679 // Include email notification flags for the panel UI
680 $result['email_enabled'] = !empty($attrs['emailEnabled']);
681 $result['send_confirmation'] = !empty($attrs['sendConfirmation']);
682
683 // Include spam data
684 if (!empty($form_id)) {
685 $result['spam_count'] = FormSubmissionHandler::GetSpamCount($form_id);
686 $result['spam_submission_count'] = FormSubmissionHandler::GetSpamSubmissionCount($form_id);
687 $result['store_spam_enabled'] = !empty($attrs['storeSpamEnabled']);
688 }
689
690 // Include integration flags for retry buttons
691 $result['mailchimp_enabled'] = !empty($attrs['mailchimpEnabled']);
692 $result['brevo_enabled'] = !empty($attrs['brevoEnabled']);
693
694 // Phase 3: Include field preferences for current user
695 if (!empty($form_id)) {
696 $user_id = get_current_user_id();
697 $field_prefs = FormSubmissionHandler::GetFieldPreference($user_id, $form_id);
698 $result['field_preferences'] = $field_prefs;
699 }
700
701 // Phase 3: Include current user ID for notes permission
702 $result['current_user_id'] = get_current_user_id();
703
704 // Phase 4: Include current user's form permissions
705 $result['permissions'] = FormPermissions::GetCurrentUserPermissions();
706
707 return rest_ensure_response($result);
708 }
709
710 /**
711 * Resend an email notification for an existing submission.
712 */
713 public static function ResendEmailCallback($request)
714 {
715 $id = intval($request['id']);
716 $params = $request->get_json_params();
717 $type = isset($params['type']) ? sanitize_text_field($params['type']) : '';
718
719 if (!in_array($type, array('admin', 'user'), true)) {
720 return new \WP_REST_Response(array(
721 'success' => false,
722 'message' => __('Invalid email type.', 'superb-blocks'),
723 ), 400);
724 }
725
726 $post = get_post($id);
727 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
728 return new \WP_REST_Response(array(
729 'success' => false,
730 'message' => __('Submission not found.', 'superb-blocks'),
731 ), 404);
732 }
733
734 $form_id = get_post_meta($id, '_spb_form_id', true);
735 $form_data = self::GetFormConfig($form_id);
736 if ($form_data === null) {
737 return new \WP_REST_Response(array(
738 'success' => false,
739 'message' => __('Form configuration not found.', 'superb-blocks'),
740 ), 404);
741 }
742
743 if ($type === 'admin' && empty($form_data['email_enabled'])) {
744 return new \WP_REST_Response(array(
745 'success' => false,
746 'message' => __('Admin notification is not enabled for this form.', 'superb-blocks'),
747 ), 400);
748 }
749 if ($type === 'user' && empty($form_data['send_confirmation'])) {
750 return new \WP_REST_Response(array(
751 'success' => false,
752 'message' => __('User notification is not enabled for this form.', 'superb-blocks'),
753 ), 400);
754 }
755
756 $fields = get_post_meta($id, '_spb_form_fields', true);
757 if (!is_array($fields)) {
758 $fields = array();
759 }
760
761 $fields = self::DecryptSubmissionFields($form_data['form_fields'], $fields);
762
763 if ($type === 'admin') {
764 $result = FormEmailHandler::SendAdminNotification($form_data, $fields, $id);
765 } else {
766 $result = FormEmailHandler::SendConfirmation($form_data, $fields, $id);
767 }
768
769 if ($result) {
770 // Return updated email status
771 $email_status = get_post_meta($id, '_spb_form_email_status', true);
772 return rest_ensure_response(array(
773 'success' => true,
774 'email_status' => is_array($email_status) ? $email_status : array(),
775 ));
776 }
777
778 return new \WP_REST_Response(array(
779 'success' => false,
780 'message' => __('Failed to send email.', 'superb-blocks'),
781 ), 500);
782 }
783
784 /**
785 * Export submissions as CSV.
786 */
787 public static function ExportCallback($request)
788 {
789 $form_id = sanitize_key($request['form_id']);
790 if (empty($form_id)) {
791 return new \WP_REST_Response(array(
792 'success' => false,
793 'message' => __('Invalid form ID.', 'superb-blocks'),
794 ), 400);
795 }
796
797 $attrs = FormRegistry::GetConfig($form_id);
798 $form_fields = (!empty($attrs) && is_array($attrs) && !empty($attrs['formFields'])) ? $attrs['formFields'] : array();
799 $pending_delete = empty($form_fields) && FormRegistry::IsPendingDelete($form_id);
800
801 $include_sensitive = isset($request['include_sensitive']) && $request['include_sensitive'] === '1' && FormPermissions::Can('sensitive');
802 $include_notes = isset($request['include_notes']) && $request['include_notes'] === '1' && FormPermissions::Can('notes');
803 $status = isset($request['status']) ? sanitize_text_field($request['status']) : '';
804 $starred = isset($request['starred']) ? sanitize_text_field($request['starred']) : '';
805 $search = isset($request['search']) ? sanitize_text_field($request['search']) : '';
806 $date_after = isset($request['date_after']) ? sanitize_text_field($request['date_after']) : '';
807 $date_before = isset($request['date_before']) ? sanitize_text_field($request['date_before']) : '';
808
809 // Phase 3: Field filtering for export
810 $export_fields = null;
811 $export_all = isset($request['export_all_fields']) && $request['export_all_fields'] === '1';
812 if (!$export_all) {
813 $user_id = get_current_user_id();
814 $field_prefs = FormSubmissionHandler::GetFieldPreference($user_id, $form_id);
815 if ($field_prefs !== null) {
816 $export_fields = $field_prefs;
817 }
818 }
819
820 FormExporter::Export($form_id, $form_fields, $include_sensitive, $status, $starred, $search, $date_after, $date_before, $include_notes, $export_fields, $pending_delete);
821 // Export streams and exits, so this line is never reached.
822 exit;
823 }
824
825 /**
826 * Get submission count for a form.
827 */
828 public static function GetSubmissionsCountCallback($request)
829 {
830 $form_id = isset($request['form_id']) ? sanitize_text_field($request['form_id']) : '';
831 $count = FormSubmissionHandler::GetCount($form_id);
832 $count['form_exists'] = FormRegistry::Get($form_id) !== null;
833 return rest_ensure_response($count);
834 }
835
836 /**
837 * Get all forms (registered + with submissions), with counts and names.
838 */
839 public static function GetSubmissionsFormsCallback()
840 {
841 $registry = FormRegistry::GetAll();
842 $form_ids_with_submissions = FormSubmissionHandler::GetDistinctFormIds();
843
844 // Merge: all registry forms + any submission-only forms not in registry
845 $all_form_ids = array_unique(array_merge(array_keys($registry), $form_ids_with_submissions));
846
847 $forms = array();
848 foreach ($all_form_ids as $form_id) {
849 $count = FormSubmissionHandler::GetCount($form_id);
850 $forms[] = array(
851 'form_id' => $form_id,
852 'form_name' => FormRegistry::GetName($form_id),
853 'total' => $count['total'],
854 'new' => $count['new'],
855 );
856 }
857
858 return rest_ensure_response($forms);
859 }
860
861 /**
862 * Bulk delete submissions.
863 */
864 public static function BulkDeleteSubmissionsCallback($request)
865 {
866 $params = $request->get_json_params();
867 $ids = isset($params['ids']) && is_array($params['ids']) ? $params['ids'] : array();
868
869 if (empty($ids)) {
870 return new \WP_REST_Response(array(
871 'success' => false,
872 'message' => __('No submissions specified.', 'superb-blocks'),
873 ), 400);
874 }
875
876 // Collect affected form IDs before deleting
877 $affected_form_ids = array();
878 foreach ($ids as $id) {
879 $fid = get_post_meta(intval($id), '_spb_form_id', true);
880 if ($fid) {
881 $affected_form_ids[sanitize_key($fid)] = true;
882 }
883 }
884
885 $deleted = FormSubmissionHandler::BulkDelete($ids);
886
887 // Clean up pending_delete forms that may now have zero submissions
888 foreach (array_keys($affected_form_ids) as $fid) {
889 FormRegistry::CleanupAfterSubmissionDelete($fid);
890 }
891
892 return rest_ensure_response(array(
893 'success' => true,
894 'deleted' => $deleted,
895 ));
896 }
897
898 /**
899 * Bulk update submission status (read/unread).
900 */
901 public static function BulkUpdateStatusCallback($request)
902 {
903 $params = $request->get_json_params();
904 $ids = isset($params['ids']) && is_array($params['ids']) ? $params['ids'] : array();
905 $status = isset($params['status']) ? sanitize_text_field($params['status']) : '';
906
907 if (empty($ids) || !in_array($status, array('read', 'new'), true)) {
908 return new \WP_REST_Response(array(
909 'success' => false,
910 'message' => __('Invalid request.', 'superb-blocks'),
911 ), 400);
912 }
913
914 $updated = FormSubmissionHandler::BulkUpdateStatus($ids, $status);
915 return rest_ensure_response(array(
916 'success' => true,
917 'updated' => $updated,
918 ));
919 }
920
921 /**
922 * Star a submission.
923 */
924 public static function StarSubmissionCallback($request)
925 {
926 $id = intval($request['id']);
927 $result = FormSubmissionHandler::Star($id);
928
929 if ($result) {
930 return rest_ensure_response(array('success' => true));
931 }
932
933 return new \WP_REST_Response(array(
934 'success' => false,
935 'message' => __('Submission not found.', 'superb-blocks'),
936 ), 404);
937 }
938
939 /**
940 * Unstar a submission.
941 */
942 public static function UnstarSubmissionCallback($request)
943 {
944 $id = intval($request['id']);
945 $result = FormSubmissionHandler::Unstar($id);
946
947 if ($result) {
948 return rest_ensure_response(array('success' => true));
949 }
950
951 return new \WP_REST_Response(array(
952 'success' => false,
953 'message' => __('Submission not found.', 'superb-blocks'),
954 ), 404);
955 }
956
957 /**
958 * Bulk star/unstar submissions.
959 */
960 public static function BulkStarCallback($request)
961 {
962 $params = $request->get_json_params();
963 $ids = isset($params['ids']) && is_array($params['ids']) ? $params['ids'] : array();
964 $star = isset($params['star']) ? (bool) $params['star'] : true;
965
966 if (empty($ids)) {
967 return new \WP_REST_Response(array(
968 'success' => false,
969 'message' => __('No submissions specified.', 'superb-blocks'),
970 ), 400);
971 }
972
973 $updated = FormSubmissionHandler::BulkStar($ids, $star);
974 return rest_ensure_response(array(
975 'success' => true,
976 'updated' => $updated,
977 ));
978 }
979
980 /**
981 * Mark a submission as read.
982 */
983 public static function MarkSubmissionReadCallback($request)
984 {
985 $id = intval($request['id']);
986 $result = FormSubmissionHandler::MarkAsRead($id);
987
988 if ($result) {
989 return rest_ensure_response(array('success' => true));
990 }
991
992 return new \WP_REST_Response(array(
993 'success' => false,
994 'message' => __('Submission not found.', 'superb-blocks'),
995 ), 404);
996 }
997
998 /**
999 * Mark a submission as unread.
1000 */
1001 public static function MarkSubmissionUnreadCallback($request)
1002 {
1003 $id = intval($request['id']);
1004 $result = FormSubmissionHandler::MarkAsUnread($id);
1005
1006 if ($result) {
1007 return rest_ensure_response(array('success' => true));
1008 }
1009
1010 return new \WP_REST_Response(array(
1011 'success' => false,
1012 'message' => __('Submission not found.', 'superb-blocks'),
1013 ), 404);
1014 }
1015
1016 /**
1017 * Delete a submission.
1018 */
1019 public static function DeleteSubmissionCallback($request)
1020 {
1021 $id = intval($request['id']);
1022 $form_id = get_post_meta($id, '_spb_form_id', true);
1023 $deleted = FormSubmissionHandler::Delete($id);
1024
1025 if ($deleted) {
1026 if ($form_id) {
1027 FormRegistry::CleanupAfterSubmissionDelete(sanitize_key($form_id));
1028 }
1029 return rest_ensure_response(array('success' => true));
1030 }
1031
1032 return new \WP_REST_Response(array(
1033 'success' => false,
1034 'message' => __('Submission not found.', 'superb-blocks'),
1035 ), 404);
1036 }
1037
1038 /**
1039 * Delete all data for a form (submissions, registry entry, config).
1040 */
1041 public static function DeleteFormCallback($request)
1042 {
1043 $form_id = sanitize_key($request['form_id']);
1044
1045 if (empty($form_id)) {
1046 return new \WP_REST_Response(array(
1047 'success' => false,
1048 'message' => __('Invalid form ID.', 'superb-blocks'),
1049 ), 400);
1050 }
1051
1052 // FORM_DELETE_ROUTE is '/form/(?P<form_id>[a-zA-Z0-9_-]+)' and overlaps with
1053 // sibling endpoints like '/form/submissions', '/form/fields', etc. Reject
1054 // reserved path segments so a DELETE to those never silently runs here.
1055 $reserved = array('submissions', 'fields', 'integrations', 'captcha', 'webhook');
1056 if (in_array($form_id, $reserved, true)) {
1057 return new \WP_REST_Response(array(
1058 'success' => false,
1059 'message' => __('Invalid form ID.', 'superb-blocks'),
1060 ), 400);
1061 }
1062
1063 // Optionally remove the form block from its source post
1064 $params = $request->get_json_params();
1065 $block_removed = false;
1066 if (!empty($params['remove_block'])) {
1067 $block_removed = FormRegistry::RemoveFormBlock($form_id);
1068 }
1069
1070 $deleted = FormSubmissionHandler::DeleteAllByFormId($form_id);
1071 FormRegistry::Remove($form_id);
1072 delete_option(FormRegistry::CONFIG_PREFIX . $form_id);
1073
1074 return rest_ensure_response(array(
1075 'success' => true,
1076 'deleted_submissions' => $deleted,
1077 'block_removed' => $block_removed,
1078 ));
1079 }
1080
1081 /**
1082 * Fetch Mailchimp lists/audiences.
1083 */
1084 public static function GetMailchimpListsCallback()
1085 {
1086 $result = FormIntegrationHandler::GetMailchimpLists();
1087 if (is_wp_error($result)) {
1088 $status = 400;
1089 $error_data = $result->get_error_data();
1090 if (isset($error_data['status'])) {
1091 $status = intval($error_data['status']);
1092 }
1093 return new \WP_REST_Response(array(
1094 'success' => false,
1095 'code' => $result->get_error_code(),
1096 'message' => $result->get_error_message(),
1097 ), $status);
1098 }
1099 return rest_ensure_response(array('lists' => $result));
1100 }
1101
1102 /**
1103 * Fetch Brevo lists.
1104 */
1105 public static function GetBrevoListsCallback()
1106 {
1107 $result = FormIntegrationHandler::GetBrevoLists();
1108 if (is_wp_error($result)) {
1109 $status = 400;
1110 $error_data = $result->get_error_data();
1111 if (isset($error_data['status'])) {
1112 $status = intval($error_data['status']);
1113 }
1114 return new \WP_REST_Response(array(
1115 'success' => false,
1116 'code' => $result->get_error_code(),
1117 'message' => $result->get_error_message(),
1118 ), $status);
1119 }
1120 return rest_ensure_response(array('lists' => $result));
1121 }
1122
1123 /**
1124 * Check whether captcha API keys are configured.
1125 */
1126 public static function GetCaptchaStatusCallback($request)
1127 {
1128 $type = isset($request['type']) ? sanitize_text_field($request['type']) : '';
1129
1130 $key_map = array(
1131 'hcaptcha' => array(FormSettings::OPTION_HCAPTCHA_SITE_KEY, FormSettings::OPTION_HCAPTCHA_SECRET_KEY),
1132 'recaptcha_v2' => array(FormSettings::OPTION_RECAPTCHA_SITE_KEY, FormSettings::OPTION_RECAPTCHA_SECRET_KEY),
1133 'recaptcha_v3' => array(FormSettings::OPTION_RECAPTCHA_SITE_KEY, FormSettings::OPTION_RECAPTCHA_SECRET_KEY),
1134 'turnstile' => array(FormSettings::OPTION_TURNSTILE_SITE_KEY, FormSettings::OPTION_TURNSTILE_SECRET_KEY),
1135 );
1136
1137 if (!isset($key_map[$type])) {
1138 return new \WP_REST_Response(array(
1139 'success' => false,
1140 'code' => 'invalid_type',
1141 'message' => __('Invalid captcha type.', 'superb-blocks'),
1142 ), 400);
1143 }
1144
1145 $keys = $key_map[$type];
1146 $site_key = FormSettings::Get($keys[0]);
1147 $secret_key = FormSettings::Get($keys[1]);
1148
1149 if (empty($site_key) || empty($secret_key)) {
1150 return new \WP_REST_Response(array(
1151 'success' => false,
1152 'code' => 'no_api_key',
1153 'message' => __('API keys are not configured for this method.', 'superb-blocks'),
1154 ), 400);
1155 }
1156
1157 return rest_ensure_response(array('success' => true));
1158 }
1159
1160 /**
1161 * Mark a spam submission as "Not Spam" (rescue to regular submissions).
1162 */
1163 public static function NotSpamCallback($request)
1164 {
1165 $id = intval($request['id']);
1166 $result = FormSubmissionHandler::MarkNotSpam($id);
1167
1168 if ($result) {
1169 return rest_ensure_response(array('success' => true));
1170 }
1171
1172 return new \WP_REST_Response(array(
1173 'success' => false,
1174 'message' => __('Submission not found or is not spam.', 'superb-blocks'),
1175 ), 404);
1176 }
1177
1178 /**
1179 * Get the spam counter for a form.
1180 */
1181 public static function GetSpamCountCallback($request)
1182 {
1183 $form_id = sanitize_key($request['form_id']);
1184 return rest_ensure_response(array(
1185 'spam_count' => FormSubmissionHandler::GetSpamCount($form_id),
1186 'spam_submission_count' => FormSubmissionHandler::GetSpamSubmissionCount($form_id),
1187 ));
1188 }
1189
1190 /**
1191 * Retry an integration (Mailchimp or Brevo) for an existing submission.
1192 */
1193 public static function RetryIntegrationCallback($request)
1194 {
1195 $id = intval($request['id']);
1196 $params = $request->get_json_params();
1197 $integration = isset($params['integration']) ? sanitize_text_field($params['integration']) : '';
1198
1199 if (!in_array($integration, array('mailchimp', 'brevo'), true)) {
1200 return new \WP_REST_Response(array(
1201 'success' => false,
1202 'message' => __('Invalid integration.', 'superb-blocks'),
1203 ), 400);
1204 }
1205
1206 $post = get_post($id);
1207 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
1208 return new \WP_REST_Response(array(
1209 'success' => false,
1210 'message' => __('Submission not found.', 'superb-blocks'),
1211 ), 404);
1212 }
1213
1214 $form_id = get_post_meta($id, '_spb_form_id', true);
1215 $form_data = self::GetFormConfig($form_id);
1216 if ($form_data === null) {
1217 return new \WP_REST_Response(array(
1218 'success' => false,
1219 'message' => __('Form configuration not found.', 'superb-blocks'),
1220 ), 404);
1221 }
1222
1223 $fields = get_post_meta($id, '_spb_form_fields', true);
1224 if (!is_array($fields)) {
1225 $fields = array();
1226 }
1227 $fields = self::DecryptSubmissionFields($form_data['form_fields'], $fields);
1228
1229 $email = self::FindSubmissionEmail($fields, $form_data['form_fields']);
1230
1231 if (empty($email)) {
1232 return new \WP_REST_Response(array(
1233 'success' => false,
1234 'message' => __('No email address found in submission fields.', 'superb-blocks'),
1235 ), 400);
1236 }
1237
1238 $result = false;
1239 $error_message = '';
1240
1241 if ($integration === 'mailchimp') {
1242 if (empty($form_data['mailchimp_enabled']) || empty($form_data['mailchimp_list_ids'])) {
1243 return new \WP_REST_Response(array(
1244 'success' => false,
1245 'message' => __('Mailchimp is not enabled for this form.', 'superb-blocks'),
1246 ), 400);
1247 }
1248 $result = FormIntegrationHandler::SendToMailchimp($form_data['mailchimp_list_ids'], $email, $fields);
1249 } elseif ($integration === 'brevo') {
1250 if (empty($form_data['brevo_enabled']) || empty($form_data['brevo_list_ids'])) {
1251 return new \WP_REST_Response(array(
1252 'success' => false,
1253 'message' => __('Brevo is not enabled for this form.', 'superb-blocks'),
1254 ), 400);
1255 }
1256 $result = FormIntegrationHandler::SendToBrevo($form_data['brevo_list_ids'], $email, $fields);
1257 }
1258
1259 // Store integration status meta
1260 $status_meta = get_post_meta($id, '_spb_form_integration_status', true);
1261 if (!is_array($status_meta)) {
1262 $status_meta = array();
1263 }
1264 $status_meta[$integration] = array(
1265 'sent' => (bool) $result,
1266 'time' => time(),
1267 'error' => $result ? null : __('Integration request failed.', 'superb-blocks'),
1268 );
1269 update_post_meta($id, '_spb_form_integration_status', $status_meta);
1270
1271 if ($result) {
1272 return rest_ensure_response(array('success' => true));
1273 }
1274
1275 return new \WP_REST_Response(array(
1276 'success' => false,
1277 'message' => __('Failed to send to integration. Please try again.', 'superb-blocks'),
1278 ), 500);
1279 }
1280
1281 // ========================================
1282 // Phase 3: Notes
1283 // ========================================
1284
1285 /**
1286 * Get notes for a submission.
1287 */
1288 public static function GetNotesCallback($request)
1289 {
1290 $id = intval($request['id']);
1291 $notes = FormSubmissionHandler::GetNotes($id);
1292 return rest_ensure_response(array(
1293 'notes' => $notes,
1294 'note_count' => count($notes),
1295 ));
1296 }
1297
1298 /**
1299 * Add a note to a submission.
1300 */
1301 public static function AddNoteCallback($request)
1302 {
1303 $id = intval($request['id']);
1304 $params = $request->get_json_params();
1305 $text = isset($params['text']) ? $params['text'] : '';
1306
1307 if (empty($text)) {
1308 return new \WP_REST_Response(array(
1309 'success' => false,
1310 'message' => __('Note text is required.', 'superb-blocks'),
1311 ), 400);
1312 }
1313
1314 if (mb_strlen($text) > 1000) {
1315 return new \WP_REST_Response(array(
1316 'success' => false,
1317 'message' => __('Note must be 1000 characters or fewer.', 'superb-blocks'),
1318 ), 400);
1319 }
1320
1321 $current_user = wp_get_current_user();
1322 $note = FormSubmissionHandler::AddNote(
1323 $id,
1324 $current_user->ID,
1325 $current_user->display_name,
1326 $text
1327 );
1328
1329 if ($note === false) {
1330 return new \WP_REST_Response(array(
1331 'success' => false,
1332 'message' => __('Failed to add note.', 'superb-blocks'),
1333 ), 400);
1334 }
1335
1336 return rest_ensure_response(array(
1337 'success' => true,
1338 'note' => $note,
1339 'notes' => FormSubmissionHandler::GetNotes($id),
1340 'note_count' => FormSubmissionHandler::GetNoteCount($id),
1341 ));
1342 }
1343
1344 /**
1345 * Delete a note from a submission.
1346 */
1347 public static function DeleteNoteCallback($request)
1348 {
1349 $id = intval($request['id']);
1350 $index = intval($request['index']);
1351 $current_user = wp_get_current_user();
1352
1353 $result = FormSubmissionHandler::DeleteNote($id, $index, $current_user->ID);
1354
1355 if (!$result) {
1356 return new \WP_REST_Response(array(
1357 'success' => false,
1358 'message' => __('Failed to delete note.', 'superb-blocks'),
1359 ), 400);
1360 }
1361
1362 return rest_ensure_response(array(
1363 'success' => true,
1364 'notes' => FormSubmissionHandler::GetNotes($id),
1365 'note_count' => FormSubmissionHandler::GetNoteCount($id),
1366 ));
1367 }
1368
1369 // ========================================
1370 // Phase 3: Field Preferences
1371 // ========================================
1372
1373 /**
1374 * Save field preferences for the current user.
1375 */
1376 public static function SaveFieldsCallback($request)
1377 {
1378 $params = $request->get_json_params();
1379 $form_id = isset($params['form_id']) ? sanitize_key($params['form_id']) : '';
1380 $fields = isset($params['fields']) && is_array($params['fields']) ? $params['fields'] : array();
1381
1382 if (empty($form_id)) {
1383 return new \WP_REST_Response(array(
1384 'success' => false,
1385 'message' => __('Invalid form ID.', 'superb-blocks'),
1386 ), 400);
1387 }
1388
1389 if (empty($fields)) {
1390 return new \WP_REST_Response(array(
1391 'success' => false,
1392 'message' => __('At least one field is required.', 'superb-blocks'),
1393 ), 400);
1394 }
1395
1396 $user_id = get_current_user_id();
1397 $result = FormSubmissionHandler::SaveFieldPreference($user_id, $form_id, $fields);
1398
1399 return rest_ensure_response(array(
1400 'success' => $result,
1401 ));
1402 }
1403
1404 /**
1405 * Get field preferences for the current user.
1406 */
1407 public static function GetFieldsCallback($request)
1408 {
1409 $form_id = sanitize_key($request['form_id']);
1410 $user_id = get_current_user_id();
1411 $fields = FormSubmissionHandler::GetFieldPreference($user_id, $form_id);
1412
1413 return rest_ensure_response(array(
1414 'fields' => $fields,
1415 ));
1416 }
1417
1418 /**
1419 * Read form configuration from server-side storage.
1420 * The config is stored as an option during save_post (FormRegistry) and block render (EnqueueForm).
1421 * This ensures all config comes from the database, not from client-supplied data.
1422 */
1423 private static function GetFormConfig($form_id)
1424 {
1425 $attrs = FormRegistry::GetConfig($form_id);
1426 if (empty($attrs) || !is_array($attrs)) {
1427 return null;
1428 }
1429
1430 return array(
1431 'form_id' => $form_id,
1432 'form_name' => isset($attrs['formName']) ? sanitize_text_field($attrs['formName']) : '',
1433 'captcha_type' => isset($attrs['captchaType']) ? sanitize_text_field($attrs['captchaType']) : 'honeypot',
1434 'honeypot_key' => isset($attrs['honeypotKey']) ? sanitize_text_field($attrs['honeypotKey']) : '',
1435 'email_enabled' => !empty($attrs['emailEnabled']),
1436 'store_enabled' => isset($attrs['storeEnabled']) ? (bool) $attrs['storeEnabled'] : false,
1437 'email_to' => isset($attrs['emailTo']) ? sanitize_text_field($attrs['emailTo']) : '',
1438 'email_subject' => isset($attrs['emailSubject']) ? sanitize_text_field($attrs['emailSubject']) : '',
1439 'email_reply_to' => isset($attrs['emailReplyTo']) ? sanitize_text_field($attrs['emailReplyTo']) : '',
1440 'email_cc' => isset($attrs['emailCC']) ? sanitize_text_field($attrs['emailCC']) : '',
1441 'email_bcc' => isset($attrs['emailBCC']) ? sanitize_text_field($attrs['emailBCC']) : '',
1442 'send_confirmation' => isset($attrs['sendConfirmation']) ? (bool) $attrs['sendConfirmation'] : false,
1443 'confirmation_subject' => isset($attrs['confirmationSubject']) ? sanitize_text_field($attrs['confirmationSubject']) : '',
1444 'confirmation_message' => isset($attrs['confirmationMessage']) ? sanitize_textarea_field($attrs['confirmationMessage']) : '',
1445 'confirmation_email_field' => isset($attrs['confirmationEmailField']) ? sanitize_text_field($attrs['confirmationEmailField']) : '',
1446 'success_behavior' => isset($attrs['successBehavior']) ? sanitize_text_field($attrs['successBehavior']) : 'message',
1447 'redirect_url' => isset($attrs['redirectUrl']) ? esc_url_raw($attrs['redirectUrl']) : '',
1448 'mailchimp_enabled' => isset($attrs['mailchimpEnabled']) ? (bool) $attrs['mailchimpEnabled'] : false,
1449 'mailchimp_list_ids' => isset($attrs['mailchimpListIds']) && is_array($attrs['mailchimpListIds'])
1450 ? array_map('sanitize_text_field', $attrs['mailchimpListIds'])
1451 : array(),
1452 'brevo_enabled' => isset($attrs['brevoEnabled']) ? (bool) $attrs['brevoEnabled'] : false,
1453 'brevo_list_ids' => isset($attrs['brevoListIds']) && is_array($attrs['brevoListIds'])
1454 ? array_map('intval', $attrs['brevoListIds'])
1455 : array(),
1456 'form_fields' => isset($attrs['formFields']) && is_array($attrs['formFields']) ? $attrs['formFields'] : array(),
1457 'store_spam_enabled' => isset($attrs['storeSpamEnabled']) ? (bool) $attrs['storeSpamEnabled'] : false,
1458 // Webhook
1459 'webhook_enabled' => isset($attrs['webhookEnabled']) ? (bool) $attrs['webhookEnabled'] : false,
1460 'webhook_url' => isset($attrs['webhookUrl']) ? esc_url_raw($attrs['webhookUrl']) : '',
1461 'webhook_method' => isset($attrs['webhookMethod']) ? sanitize_text_field($attrs['webhookMethod']) : 'POST',
1462 'webhook_secret' => FormSettings::GetWebhookSecret($form_id),
1463 'webhook_headers' => isset($attrs['webhookHeaders']) && is_array($attrs['webhookHeaders']) ? $attrs['webhookHeaders'] : array(),
1464 // Google Sheets
1465 'google_sheets_enabled' => isset($attrs['googleSheetsEnabled']) ? (bool) $attrs['googleSheetsEnabled'] : false,
1466 'google_sheets_spreadsheet_url' => isset($attrs['googleSheetsSpreadsheetUrl']) ? sanitize_text_field($attrs['googleSheetsSpreadsheetUrl']) : '',
1467 'google_sheets_sheet_name' => isset($attrs['googleSheetsSheetName']) ? sanitize_text_field($attrs['googleSheetsSheetName']) : '',
1468 // Slack
1469 'slack_enabled' => isset($attrs['slackEnabled']) ? (bool) $attrs['slackEnabled'] : false,
1470 'slack_webhook_url' => isset($attrs['slackWebhookUrl']) ? esc_url_raw($attrs['slackWebhookUrl']) : '',
1471 );
1472 }
1473
1474 /**
1475 * Find the submission's primary email for integration delivery.
1476 * Prefers fields explicitly typed as 'email' in the form config; falls back to
1477 * the first string that passes is_email() so legacy forms without typing still work.
1478 */
1479 private static function FindSubmissionEmail($fields, $form_fields)
1480 {
1481 if (is_array($form_fields)) {
1482 foreach ($form_fields as $fc) {
1483 if (!isset($fc['fieldType'], $fc['fieldId'])) {
1484 continue;
1485 }
1486 if ($fc['fieldType'] !== 'email') {
1487 continue;
1488 }
1489 $fid = $fc['fieldId'];
1490 if (isset($fields[$fid]) && is_string($fields[$fid]) && is_email($fields[$fid])) {
1491 return $fields[$fid];
1492 }
1493 }
1494 }
1495 foreach ($fields as $value) {
1496 if (is_string($value) && is_email($value)) {
1497 return $value;
1498 }
1499 }
1500 return '';
1501 }
1502
1503 /**
1504 * Decrypt sensitive fields in a submission's field data.
1505 *
1506 * @param array $form_fields Array of field definitions (with fieldId/sensitive flags).
1507 * @param array $fields Submission field data (field_id => value).
1508 * @param bool $pending_delete Whether the form is pending deletion (config unavailable).
1509 * @return array The fields array with sensitive values decrypted.
1510 */
1511 private static function DecryptSubmissionFields($form_fields, $fields, $pending_delete = false)
1512 {
1513 if (empty($form_fields) && $pending_delete) {
1514 // Config is gone — detect sensitive fields by encryption prefix
1515 foreach ($fields as $fid => $value) {
1516 if (FormEncryption::IsEncrypted($value)) {
1517 $decrypted = FormEncryption::Decrypt($value);
1518 if ($decrypted !== false) {
1519 $fields[$fid] = $decrypted;
1520 }
1521 }
1522 }
1523 return $fields;
1524 }
1525
1526 foreach ($form_fields as $field_def) {
1527 if (!empty($field_def['sensitive']) && !empty($field_def['fieldId'])) {
1528 $sfid = $field_def['fieldId'];
1529 if (isset($fields[$sfid]) && is_string($fields[$sfid])) {
1530 $decrypted = FormEncryption::Decrypt($fields[$sfid]);
1531 if ($decrypted !== false) {
1532 $fields[$sfid] = $decrypted;
1533 }
1534 }
1535 }
1536 }
1537 return $fields;
1538 }
1539
1540 /**
1541 * Serve a file from a submission (admin-only).
1542 */
1543 public static function ServeFileCallback($request)
1544 {
1545 $post_id = intval($request['id']);
1546 $field_id = sanitize_text_field($request['field_id']);
1547 $index = intval($request['index']);
1548
1549 $post = get_post($post_id);
1550 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
1551 return new \WP_REST_Response(array(
1552 'success' => false,
1553 'message' => __('Submission not found.', 'superb-blocks'),
1554 ), 404);
1555 }
1556
1557 $fields = get_post_meta($post_id, '_spb_form_fields', true);
1558 if (!is_array($fields) || !isset($fields[$field_id]) || !is_array($fields[$field_id])) {
1559 return new \WP_REST_Response(array(
1560 'success' => false,
1561 'message' => __('File not found.', 'superb-blocks'),
1562 ), 404);
1563 }
1564
1565 $file_list = $fields[$field_id];
1566 if (!isset($file_list[$index]) || !is_array($file_list[$index])) {
1567 return new \WP_REST_Response(array(
1568 'success' => false,
1569 'message' => __('File not found.', 'superb-blocks'),
1570 ), 404);
1571 }
1572
1573 $file_meta = $file_list[$index];
1574 $file_path = isset($file_meta['path']) ? $file_meta['path'] : '';
1575 $original_name = isset($file_meta['name']) ? $file_meta['name'] : 'download';
1576 $mime_type = isset($file_meta['type']) && $file_meta['type'] !== '' ? $file_meta['type'] : 'application/octet-stream';
1577
1578 return FormFileHandler::ServeFile($file_path, $original_name, $mime_type);
1579 }
1580
1581 /**
1582 * Process email list integrations.
1583 *
1584 * @param array $form_data
1585 * @param array $fields
1586 * @param int $post_id Submission post ID for status tracking
1587 */
1588 private static function ProcessIntegrations($form_data, $fields, $post_id = 0)
1589 {
1590 $integration_status = array();
1591 $form_fields_config = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
1592
1593 // Webhook (no email required)
1594 if (!empty($form_data['webhook_enabled']) && !empty($form_data['webhook_url'])) {
1595 $result = FormIntegrationHandler::SendWebhook(
1596 $form_data['webhook_url'],
1597 $form_data['webhook_method'],
1598 $form_data['form_id'],
1599 $form_data['form_name'],
1600 $fields,
1601 $form_fields_config,
1602 isset($form_data['webhook_secret']) ? $form_data['webhook_secret'] : '',
1603 isset($form_data['webhook_headers']) ? $form_data['webhook_headers'] : array()
1604 );
1605 $integration_status['webhook'] = array(
1606 'sent' => !empty($result['sent']),
1607 'time' => time(),
1608 'code' => isset($result['code']) ? $result['code'] : 0,
1609 'error' => isset($result['error']) ? $result['error'] : null,
1610 );
1611 }
1612
1613 // Google Sheets (no email required)
1614 if (!empty($form_data['google_sheets_enabled']) && !empty($form_data['google_sheets_spreadsheet_url'])) {
1615 $result = FormIntegrationHandler::SendToGoogleSheets(
1616 $form_data['google_sheets_spreadsheet_url'],
1617 isset($form_data['google_sheets_sheet_name']) ? $form_data['google_sheets_sheet_name'] : '',
1618 $fields,
1619 $form_fields_config
1620 );
1621 $integration_status['google_sheets'] = array(
1622 'sent' => !empty($result['sent']),
1623 'time' => time(),
1624 'error' => isset($result['error']) ? $result['error'] : null,
1625 );
1626 }
1627
1628 // Slack (no email required)
1629 if (!empty($form_data['slack_enabled']) && !empty($form_data['slack_webhook_url'])) {
1630 $result = FormIntegrationHandler::SendToSlack(
1631 $form_data['slack_webhook_url'],
1632 $form_data['form_name'],
1633 $fields,
1634 $form_fields_config
1635 );
1636 $integration_status['slack'] = array(
1637 'sent' => !empty($result['sent']),
1638 'time' => time(),
1639 'error' => isset($result['error']) ? $result['error'] : null,
1640 );
1641 }
1642
1643 // Find email from submitted fields (required for Mailchimp/Brevo)
1644 $email = self::FindSubmissionEmail($fields, $form_fields_config);
1645
1646 if (!empty($email)) {
1647 // Mailchimp
1648 if (!empty($form_data['mailchimp_enabled']) && !empty($form_data['mailchimp_list_ids'])) {
1649 $result = FormIntegrationHandler::SendToMailchimp(
1650 $form_data['mailchimp_list_ids'],
1651 $email,
1652 $fields
1653 );
1654 $integration_status['mailchimp'] = array(
1655 'sent' => (bool) $result,
1656 'time' => time(),
1657 'error' => $result ? null : __('Mailchimp request failed.', 'superb-blocks'),
1658 );
1659 }
1660
1661 // Brevo
1662 if (!empty($form_data['brevo_enabled']) && !empty($form_data['brevo_list_ids'])) {
1663 $result = FormIntegrationHandler::SendToBrevo(
1664 $form_data['brevo_list_ids'],
1665 $email,
1666 $fields
1667 );
1668 $integration_status['brevo'] = array(
1669 'sent' => (bool) $result,
1670 'time' => time(),
1671 'error' => $result ? null : __('Brevo request failed.', 'superb-blocks'),
1672 );
1673 }
1674 }
1675
1676 // Store integration status on submission
1677 if ($post_id > 0 && !empty($integration_status)) {
1678 update_post_meta($post_id, '_spb_form_integration_status', $integration_status);
1679 }
1680 }
1681
1682 /**
1683 * Webhook test endpoint callback.
1684 */
1685 public static function WebhookTestCallback($request)
1686 {
1687 $url = isset($request['url']) ? esc_url_raw($request['url']) : '';
1688 $method = isset($request['method']) ? sanitize_text_field($request['method']) : 'POST';
1689 $secret = isset($request['secret']) ? sanitize_text_field($request['secret']) : '';
1690 $headers = isset($request['headers']) && is_array($request['headers']) ? $request['headers'] : array();
1691
1692 if (empty($url)) {
1693 return new \WP_REST_Response(array(
1694 'success' => false,
1695 'status_code' => 0,
1696 'error' => __('URL is required.', 'superb-blocks'),
1697 ), 400);
1698 }
1699
1700 $validated_url = wp_http_validate_url($url);
1701 if (!$validated_url) {
1702 return new \WP_REST_Response(array(
1703 'success' => false,
1704 'status_code' => 0,
1705 'error' => __('Invalid or blocked URL.', 'superb-blocks'),
1706 ), 400);
1707 }
1708 $url = $validated_url;
1709
1710 $test_fields = array(
1711 'test_field' => array(
1712 'label' => 'Name',
1713 'value' => 'Test Submission',
1714 'type' => 'text',
1715 ),
1716 'test_email' => array(
1717 'label' => 'Email',
1718 'value' => 'test@example.com',
1719 'type' => 'email',
1720 ),
1721 );
1722
1723 $payload = array(
1724 'form_id' => 'test',
1725 'form_name' => 'Test Form',
1726 'submitted_at' => gmdate('c'),
1727 'test' => true,
1728 'fields' => $test_fields,
1729 );
1730
1731 $json = wp_json_encode($payload);
1732 if ($json === false) {
1733 return new \WP_REST_Response(array(
1734 'success' => false,
1735 'status_code' => 0,
1736 'error' => __('Failed to encode test payload.', 'superb-blocks'),
1737 ), 500);
1738 }
1739
1740 $request_headers = array(
1741 'Content-Type' => 'application/json',
1742 'User-Agent' => 'SuperbAddons/' . SUPERBADDONS_VERSION,
1743 );
1744
1745 if (!empty($secret)) {
1746 $request_headers['X-Superb-Signature'] = 'sha256=' . hash_hmac('sha256', $json, $secret);
1747 }
1748
1749 if (is_array($headers)) {
1750 foreach ($headers as $h) {
1751 if (!empty($h['key'])) {
1752 $request_headers[sanitize_text_field($h['key'])] = sanitize_text_field(isset($h['value']) ? $h['value'] : '');
1753 }
1754 }
1755 }
1756
1757 $allowed_methods = array('POST', 'PUT', 'PATCH');
1758 if (!in_array(strtoupper($method), $allowed_methods, true)) {
1759 $method = 'POST';
1760 }
1761
1762 $response = wp_remote_request($url, array(
1763 'method' => strtoupper($method),
1764 'headers' => $request_headers,
1765 'body' => $json,
1766 'timeout' => 15,
1767 ));
1768
1769 if (is_wp_error($response)) {
1770 return rest_ensure_response(array(
1771 'success' => false,
1772 'status_code' => 0,
1773 'error' => $response->get_error_message(),
1774 ));
1775 }
1776
1777 $code = wp_remote_retrieve_response_code($response);
1778 return rest_ensure_response(array(
1779 'success' => $code >= 200 && $code < 300,
1780 'status_code' => $code,
1781 'error' => ($code >= 200 && $code < 300) ? null : sprintf('HTTP %d', $code),
1782 ));
1783 }
1784
1785 /**
1786 * Google Sheets status endpoint callback.
1787 */
1788 public static function GoogleSheetsStatusCallback()
1789 {
1790 $client_email = FormSettings::Get(FormSettings::OPTION_GOOGLE_SHEETS_CLIENT_EMAIL);
1791 $configured = !empty($client_email) && FormSettings::HasValue(FormSettings::OPTION_GOOGLE_SHEETS_PRIVATE_KEY);
1792
1793 return rest_ensure_response(array(
1794 'configured' => $configured,
1795 'client_email' => $configured ? $client_email : '',
1796 ));
1797 }
1798
1799 /**
1800 * Google Sheets test connection endpoint callback.
1801 */
1802 public static function GoogleSheetsTestCallback($request)
1803 {
1804 $spreadsheet_url = isset($request['spreadsheet_url']) ? sanitize_text_field($request['spreadsheet_url']) : '';
1805 $sheet_name = isset($request['sheet_name']) ? sanitize_text_field($request['sheet_name']) : '';
1806
1807 if (empty($spreadsheet_url)) {
1808 return rest_ensure_response(array(
1809 'success' => false,
1810 'error' => __('Spreadsheet URL is required.', 'superb-blocks'),
1811 ));
1812 }
1813
1814 // Extract spreadsheet ID from URL
1815 $spreadsheet_id = $spreadsheet_url;
1816 if (preg_match('/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/', $spreadsheet_url, $matches)) {
1817 $spreadsheet_id = $matches[1];
1818 }
1819
1820 $token = FormGoogleAuth::GetAccessToken();
1821 if (is_wp_error($token)) {
1822 return rest_ensure_response(array(
1823 'success' => false,
1824 'error' => $token->get_error_message(),
1825 ));
1826 }
1827
1828 $range = !empty($sheet_name) ? $sheet_name : 'Sheet1';
1829 $url = 'https://sheets.googleapis.com/v4/spreadsheets/' . urlencode($spreadsheet_id) . '/values/' . urlencode($range . '!A1');
1830
1831 $response = wp_remote_get($url, array(
1832 'headers' => array('Authorization' => 'Bearer ' . $token),
1833 'timeout' => 15,
1834 ));
1835
1836 if (is_wp_error($response)) {
1837 return rest_ensure_response(array(
1838 'success' => false,
1839 'error' => $response->get_error_message(),
1840 ));
1841 }
1842
1843 $code = wp_remote_retrieve_response_code($response);
1844 if ($code >= 200 && $code < 300) {
1845 return rest_ensure_response(array('success' => true, 'error' => null));
1846 }
1847
1848 $body = json_decode(wp_remote_retrieve_body($response), true);
1849 $msg = isset($body['error']['message']) ? $body['error']['message'] : sprintf('HTTP %d', $code);
1850 return rest_ensure_response(array('success' => false, 'error' => $msg));
1851 }
1852
1853 /**
1854 * Webhook secret endpoint: GET (status), POST (save), DELETE (remove).
1855 */
1856 public static function WebhookSecretCallback($request)
1857 {
1858 $form_id = isset($request['form_id']) ? sanitize_key($request['form_id']) : '';
1859 if (empty($form_id)) {
1860 return new \WP_REST_Response(array('success' => false, 'error' => 'Missing form_id.'), 400);
1861 }
1862
1863 $method = $request->get_method();
1864
1865 if ($method === 'GET') {
1866 return rest_ensure_response(array(
1867 'has_secret' => FormSettings::HasWebhookSecret($form_id),
1868 ));
1869 }
1870
1871 if ($method === 'DELETE') {
1872 FormSettings::RemoveWebhookSecret($form_id);
1873 return rest_ensure_response(array('success' => true));
1874 }
1875
1876 // POST: save secret
1877 $secret = isset($request['secret']) ? sanitize_text_field($request['secret']) : '';
1878 if (empty($secret)) {
1879 return new \WP_REST_Response(array('success' => false, 'error' => __('Secret cannot be empty.', 'superb-blocks')), 400);
1880 }
1881
1882 FormSettings::SetWebhookSecret($form_id, $secret);
1883 return rest_ensure_response(array('success' => true));
1884 }
1885
1886 /**
1887 * Slack test endpoint callback.
1888 */
1889 public static function SlackTestCallback($request)
1890 {
1891 $webhook_url = isset($request['url']) ? esc_url_raw($request['url']) : '';
1892
1893 if (empty($webhook_url)) {
1894 return new \WP_REST_Response(array(
1895 'success' => false,
1896 'error' => __('Webhook URL is required.', 'superb-blocks'),
1897 ), 400);
1898 }
1899
1900 $validated_url = wp_http_validate_url($webhook_url);
1901 if (!$validated_url) {
1902 return new \WP_REST_Response(array(
1903 'success' => false,
1904 'error' => __('Invalid or blocked URL.', 'superb-blocks'),
1905 ), 400);
1906 }
1907
1908 $test_fields = array(
1909 'test_name' => 'Jane Smith',
1910 'test_email' => 'test@example.com',
1911 );
1912 $test_config = array(
1913 array('fieldId' => 'test_name', 'label' => 'Name', 'fieldType' => 'text'),
1914 array('fieldId' => 'test_email', 'label' => 'Email', 'fieldType' => 'email'),
1915 );
1916
1917 $result = FormIntegrationHandler::SendToSlack(
1918 $validated_url,
1919 __('Test Form', 'superb-blocks'),
1920 $test_fields,
1921 $test_config
1922 );
1923
1924 return rest_ensure_response(array(
1925 'success' => !empty($result['sent']),
1926 'error' => isset($result['error']) ? $result['error'] : null,
1927 ));
1928 }
1929 }
1930