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