PluginProbe ʕ •ᴥ•ʔ
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More / 4.0.7
Superb Addons: Blocks, Patterns, Pre-built Pages, Sliders, Popups, Free Forms, Animations & More v4.0.7
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 4 days ago class-form-captcha-handler.php 4 days ago class-form-controller.php 4 days ago class-form-email-config-check.php 4 days ago class-form-email-handler.php 4 days ago class-form-encryption.php 4 days ago class-form-exporter.php 4 days ago class-form-field-validator.php 4 days ago class-form-file-handler.php 4 days ago class-form-google-auth.php 4 days ago class-form-integration-handler.php 4 days ago class-form-math-parser.php 4 days ago class-form-permissions.php 4 days ago class-form-registry.php 4 days ago class-form-settings.php 4 days ago class-form-submission-cpt.php 4 days ago class-form-submission-handler.php 4 days ago
class-form-controller.php
1947 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 $validation_result = FormFieldValidator::Validate($fields, $form_fields);
466 $fields = $validation_result['fields'];
467
468 if (!empty($validation_result['errors'])) {
469 return new \WP_REST_Response(array(
470 'success' => false,
471 'message' => __('Please correct the errors below.', 'superb-blocks'),
472 'errors' => $validation_result['errors'],
473 ), 400);
474 }
475
476 // Type-aware sanitization
477 $sanitized_fields = array();
478 $field_type_lookup = array();
479 foreach ($form_fields as $fc) {
480 if (isset($fc['fieldId'])) {
481 $field_type_lookup[$fc['fieldId']] = isset($fc['fieldType']) ? $fc['fieldType'] : 'text';
482 }
483 }
484 foreach ($fields as $key => $value) {
485 $skey = sanitize_text_field($key);
486 $ftype = isset($field_type_lookup[$skey]) ? $field_type_lookup[$skey] : 'text';
487
488 if ($ftype === 'textarea') {
489 $sanitized_fields[$skey] = sanitize_textarea_field($value);
490 } elseif ($ftype === 'signature') {
491 // Signature stores a PNG data URL. sanitize_text_field would mangle the base64.
492 // Validation already ensures correct format and size in FormFieldValidator.
493 $prefix = 'data:image/png;base64,';
494 if (strpos($value, $prefix) === 0 && strlen($value) <= 500000) {
495 $sanitized_fields[$skey] = $value;
496 } else {
497 $sanitized_fields[$skey] = '';
498 }
499 } else {
500 $sanitized_fields[$skey] = sanitize_text_field($value);
501 }
502 }
503
504 // Recalculate calculated fields server-side (don't trust client values)
505 foreach ($form_fields as $fc) {
506 if (isset($fc['fieldType']) && $fc['fieldType'] === 'calculated' && isset($fc['fieldId'])) {
507 $calc_id = $fc['fieldId'];
508 $cs = isset($fc['calculatedSettings']) && is_array($fc['calculatedSettings'])
509 ? $fc['calculatedSettings']
510 : array();
511 $formula = isset($cs['formula']) ? $cs['formula'] : '';
512 $round_result = isset($cs['roundResult']) ? intval($cs['roundResult']) : -1;
513
514 if ($formula !== '') {
515 $result = FormMathParser::Evaluate($formula, $sanitized_fields, $round_result);
516 $sanitized_fields[$calc_id] = strval($result);
517 }
518 }
519 }
520
521 // Process file uploads
522 $file_data = array();
523 if (!empty($_FILES['files'])) {
524 $file_data = FormFileHandler::ProcessUploads($form_fields);
525 // Merge file metadata into sanitized fields for storage
526 foreach ($file_data as $fid => $ffiles) {
527 $sanitized_fields[$fid] = $ffiles;
528 }
529 }
530
531 if (empty($sanitized_fields)) {
532 return new \WP_REST_Response(array(
533 'success' => false,
534 'message' => __('No fields submitted.', 'superb-blocks'),
535 ), 400);
536 }
537
538 // Store submission if enabled
539 $submission_post_id = 0;
540 if (!empty($form_data['store_enabled'])) {
541 $storage_fields = $sanitized_fields;
542 // Encrypt sensitive fields before storage
543 foreach ($form_fields as $fc) {
544 $fid = isset($fc['fieldId']) ? $fc['fieldId'] : '';
545 if (!empty($fc['sensitive']) && $fid !== '' && isset($storage_fields[$fid]) && is_string($storage_fields[$fid])) {
546 $storage_fields[$fid] = FormEncryption::Encrypt($storage_fields[$fid]);
547 }
548 }
549 $submission_post_id = FormSubmissionHandler::Store($form_id, $storage_fields);
550 if ($submission_post_id === false) {
551 $submission_post_id = 0;
552 }
553 }
554
555 // Send admin notification email
556 if (!empty($form_data['email_enabled'])) {
557 $to = !empty($form_data['email_to']) ? $form_data['email_to'] : get_option('admin_email');
558 $valid_emails = array_filter(array_map('trim', explode(',', $to)), 'is_email');
559 if (!empty($valid_emails)) {
560 $form_data['email_to'] = implode(',', array_map('sanitize_email', $valid_emails));
561 FormEmailHandler::SendAdminNotification($form_data, $sanitized_fields, $submission_post_id);
562 }
563 }
564
565 // Send user confirmation
566 if (!empty($form_data['send_confirmation'])) {
567 FormEmailHandler::SendConfirmation($form_data, $sanitized_fields, $submission_post_id);
568 }
569
570 // Send to integrations and track status
571 self::ProcessIntegrations($form_data, $sanitized_fields, $submission_post_id);
572
573 // Premium hook
574 do_action('superbaddons_form_after_submit', $form_id, $sanitized_fields, $form_data);
575
576 // No submission post was stored (storage disabled, or Store() failed),
577 // so the uploaded files have no record tying them to anything and would
578 // otherwise orphan on disk forever. They have already served the
579 // notification email, integrations, and the premium hook above, so this
580 // is the last point at which they are needed. Clean them up now.
581 if ($submission_post_id === 0 && !empty($file_data)) {
582 FormFileHandler::DeleteSubmissionFiles($file_data);
583 }
584
585 $response = array(
586 'success' => true,
587 'message' => __('Form submitted successfully.', 'superb-blocks'),
588 );
589
590 // Include redirect URL from server-side config (not client-supplied)
591 $success_behavior = isset($form_data['success_behavior']) ? $form_data['success_behavior'] : 'message';
592 $redirect_url = isset($form_data['redirect_url']) ? $form_data['redirect_url'] : '';
593 if ($success_behavior === 'redirect' && !empty($redirect_url)) {
594 $response['redirect_url'] = esc_url($redirect_url);
595 }
596
597 return rest_ensure_response($response);
598 }
599
600 /**
601 * Get submissions for a form.
602 */
603 public static function GetSubmissionsCallback($request)
604 {
605 $form_id = isset($request['form_id']) ? sanitize_text_field($request['form_id']) : '';
606 $page = isset($request['page']) ? intval($request['page']) : 1;
607 $per_page = isset($request['per_page']) ? intval($request['per_page']) : 20;
608 $status = isset($request['status']) ? sanitize_text_field($request['status']) : '';
609 $starred = isset($request['starred']) ? sanitize_text_field($request['starred']) : '';
610 $search = isset($request['search']) ? sanitize_text_field($request['search']) : '';
611 $date_after = isset($request['date_after']) ? sanitize_text_field($request['date_after']) : '';
612 $date_before = isset($request['date_before']) ? sanitize_text_field($request['date_before']) : '';
613
614 // Cap per_page to prevent abuse
615 if ($per_page < 1) {
616 $per_page = 20;
617 }
618 if ($per_page > 100) {
619 $per_page = 100;
620 }
621
622 $result = FormSubmissionHandler::GetSubmissions($form_id, $page, $per_page, $status, $starred, $search, $date_after, $date_before);
623
624 // Include counts for filter tabs
625 if (!empty($form_id)) {
626 $counts = FormSubmissionHandler::GetCount($form_id);
627 $result['count_total'] = $counts['total'];
628 $result['count_new'] = $counts['new'];
629 $result['count_read'] = $counts['total'] - $counts['new'];
630 }
631
632 // Load form config once (kept as array so downstream !empty()/is_array() checks stay safe)
633 $attrs = array();
634 if (!empty($form_id)) {
635 $loaded = FormRegistry::GetConfig($form_id);
636 if (is_array($loaded)) {
637 $attrs = $loaded;
638 }
639 }
640
641 // Include field labels from form config
642 $field_labels = array();
643 if (!empty($attrs['formFields']) && is_array($attrs['formFields'])) {
644 foreach ($attrs['formFields'] as $field) {
645 if (isset($field['fieldId']) && isset($field['label'])) {
646 $field_labels[$field['fieldId']] = $field['label'];
647 }
648 }
649 }
650
651 $result['field_labels'] = $field_labels;
652
653 // Build sensitive field lookup and decrypt stored values
654 $form_fields_config = (!empty($attrs['formFields']) && is_array($attrs['formFields'])) ? $attrs['formFields'] : array();
655 $pending_delete = !empty($form_id) && empty($form_fields_config) && FormRegistry::IsPendingDelete($form_id);
656 $sensitive_fields = array();
657 foreach ($form_fields_config as $field) {
658 if (!empty($field['sensitive']) && !empty($field['fieldId'])) {
659 $sensitive_fields[] = $field['fieldId'];
660 }
661 }
662 // Pass 1 (pending_delete only): discover sensitive fields across ALL submissions
663 // by encryption prefix before we decrypt anything. Without this, a submission whose
664 // plaintext predates encryption would leak unmasked while later encrypted rows mask correctly.
665 if ($pending_delete) {
666 foreach ($result['submissions'] as $sub) {
667 if (empty($sub['fields']) || !is_array($sub['fields'])) {
668 continue;
669 }
670 foreach ($sub['fields'] as $fid => $value) {
671 if (FormEncryption::IsEncrypted($value) && !in_array($fid, $sensitive_fields, true)) {
672 $sensitive_fields[] = $fid;
673 }
674 }
675 }
676 }
677 // Pass 2: decrypt and mask using the complete sensitive_fields list
678 $can_view_sensitive = FormPermissions::Can('sensitive');
679 foreach ($result['submissions'] as &$sub) {
680 $sub['fields'] = self::DecryptSubmissionFields($form_fields_config, $sub['fields'], $pending_delete);
681 if (!$can_view_sensitive && !empty($sensitive_fields)) {
682 foreach ($sensitive_fields as $sfid) {
683 if (isset($sub['fields'][$sfid]) && is_string($sub['fields'][$sfid]) && $sub['fields'][$sfid] !== '') {
684 $sub['fields'][$sfid] = str_repeat("\xE2\x80\xA2", 8);
685 }
686 }
687 }
688 }
689 unset($sub);
690 $result['sensitive_fields'] = $sensitive_fields;
691 $result['can_view_sensitive'] = $can_view_sensitive;
692
693 // Include email notification flags for the panel UI
694 $result['email_enabled'] = !empty($attrs['emailEnabled']);
695 $result['send_confirmation'] = !empty($attrs['sendConfirmation']);
696
697 // Include spam data
698 if (!empty($form_id)) {
699 $result['spam_count'] = FormSubmissionHandler::GetSpamCount($form_id);
700 $result['spam_submission_count'] = FormSubmissionHandler::GetSpamSubmissionCount($form_id);
701 $result['store_spam_enabled'] = !empty($attrs['storeSpamEnabled']);
702 }
703
704 // Include integration flags for retry buttons
705 $result['mailchimp_enabled'] = !empty($attrs['mailchimpEnabled']);
706 $result['brevo_enabled'] = !empty($attrs['brevoEnabled']);
707
708 // Phase 3: Include field preferences for current user
709 if (!empty($form_id)) {
710 $user_id = get_current_user_id();
711 $field_prefs = FormSubmissionHandler::GetFieldPreference($user_id, $form_id);
712 $result['field_preferences'] = $field_prefs;
713 }
714
715 // Phase 3: Include current user ID for notes permission
716 $result['current_user_id'] = get_current_user_id();
717
718 // Phase 4: Include current user's form permissions
719 $result['permissions'] = FormPermissions::GetCurrentUserPermissions();
720
721 return rest_ensure_response($result);
722 }
723
724 /**
725 * Resend an email notification for an existing submission.
726 */
727 public static function ResendEmailCallback($request)
728 {
729 $id = intval($request['id']);
730 $params = $request->get_json_params();
731 $type = isset($params['type']) ? sanitize_text_field($params['type']) : '';
732
733 if (!in_array($type, array('admin', 'user'), true)) {
734 return new \WP_REST_Response(array(
735 'success' => false,
736 'message' => __('Invalid email type.', 'superb-blocks'),
737 ), 400);
738 }
739
740 $post = get_post($id);
741 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
742 return new \WP_REST_Response(array(
743 'success' => false,
744 'message' => __('Submission not found.', 'superb-blocks'),
745 ), 404);
746 }
747
748 $form_id = get_post_meta($id, '_spb_form_id', true);
749 $form_data = self::GetFormConfig($form_id);
750 if ($form_data === null) {
751 return new \WP_REST_Response(array(
752 'success' => false,
753 'message' => __('Form configuration not found.', 'superb-blocks'),
754 ), 404);
755 }
756
757 if ($type === 'admin' && empty($form_data['email_enabled'])) {
758 return new \WP_REST_Response(array(
759 'success' => false,
760 'message' => __('Admin notification is not enabled for this form.', 'superb-blocks'),
761 ), 400);
762 }
763 if ($type === 'user' && empty($form_data['send_confirmation'])) {
764 return new \WP_REST_Response(array(
765 'success' => false,
766 'message' => __('User notification is not enabled for this form.', 'superb-blocks'),
767 ), 400);
768 }
769
770 $fields = get_post_meta($id, '_spb_form_fields', true);
771 if (!is_array($fields)) {
772 $fields = array();
773 }
774
775 $fields = self::DecryptSubmissionFields($form_data['form_fields'], $fields);
776
777 if ($type === 'admin') {
778 $result = FormEmailHandler::SendAdminNotification($form_data, $fields, $id);
779 } else {
780 $result = FormEmailHandler::SendConfirmation($form_data, $fields, $id);
781 }
782
783 if ($result) {
784 // Return updated email status
785 $email_status = get_post_meta($id, '_spb_form_email_status', true);
786 return rest_ensure_response(array(
787 'success' => true,
788 'email_status' => is_array($email_status) ? $email_status : array(),
789 ));
790 }
791
792 return new \WP_REST_Response(array(
793 'success' => false,
794 'message' => __('Failed to send email.', 'superb-blocks'),
795 ), 500);
796 }
797
798 /**
799 * Export submissions as CSV.
800 */
801 public static function ExportCallback($request)
802 {
803 $form_id = sanitize_key($request['form_id']);
804 if (empty($form_id)) {
805 return new \WP_REST_Response(array(
806 'success' => false,
807 'message' => __('Invalid form ID.', 'superb-blocks'),
808 ), 400);
809 }
810
811 $attrs = FormRegistry::GetConfig($form_id);
812 $form_fields = (!empty($attrs) && is_array($attrs) && !empty($attrs['formFields'])) ? $attrs['formFields'] : array();
813 $pending_delete = empty($form_fields) && FormRegistry::IsPendingDelete($form_id);
814
815 $include_sensitive = isset($request['include_sensitive']) && $request['include_sensitive'] === '1' && FormPermissions::Can('sensitive');
816 $include_notes = isset($request['include_notes']) && $request['include_notes'] === '1' && FormPermissions::Can('notes');
817 $status = isset($request['status']) ? sanitize_text_field($request['status']) : '';
818 $starred = isset($request['starred']) ? sanitize_text_field($request['starred']) : '';
819 $search = isset($request['search']) ? sanitize_text_field($request['search']) : '';
820 $date_after = isset($request['date_after']) ? sanitize_text_field($request['date_after']) : '';
821 $date_before = isset($request['date_before']) ? sanitize_text_field($request['date_before']) : '';
822
823 // Phase 3: Field filtering for export
824 $export_fields = null;
825 $export_all = isset($request['export_all_fields']) && $request['export_all_fields'] === '1';
826 if (!$export_all) {
827 $user_id = get_current_user_id();
828 $field_prefs = FormSubmissionHandler::GetFieldPreference($user_id, $form_id);
829 if ($field_prefs !== null) {
830 $export_fields = $field_prefs;
831 }
832 }
833
834 FormExporter::Export($form_id, $form_fields, $include_sensitive, $status, $starred, $search, $date_after, $date_before, $include_notes, $export_fields, $pending_delete);
835 // Export streams and exits, so this line is never reached.
836 exit;
837 }
838
839 /**
840 * Get submission count for a form.
841 */
842 public static function GetSubmissionsCountCallback($request)
843 {
844 $form_id = isset($request['form_id']) ? sanitize_text_field($request['form_id']) : '';
845 $count = FormSubmissionHandler::GetCount($form_id);
846 $count['form_exists'] = FormRegistry::Get($form_id) !== null;
847 return rest_ensure_response($count);
848 }
849
850 /**
851 * Get all forms (registered + with submissions), with counts and names.
852 */
853 public static function GetSubmissionsFormsCallback()
854 {
855 $registry = FormRegistry::GetAll();
856 $form_ids_with_submissions = FormSubmissionHandler::GetDistinctFormIds();
857
858 // Merge: all registry forms + any submission-only forms not in registry
859 $all_form_ids = array_unique(array_merge(array_keys($registry), $form_ids_with_submissions));
860
861 $forms = array();
862 foreach ($all_form_ids as $form_id) {
863 $count = FormSubmissionHandler::GetCount($form_id);
864 $forms[] = array(
865 'form_id' => $form_id,
866 'form_name' => FormRegistry::GetName($form_id),
867 'total' => $count['total'],
868 'new' => $count['new'],
869 );
870 }
871
872 return rest_ensure_response($forms);
873 }
874
875 /**
876 * Bulk delete submissions.
877 */
878 public static function BulkDeleteSubmissionsCallback($request)
879 {
880 $params = $request->get_json_params();
881 $ids = isset($params['ids']) && is_array($params['ids']) ? $params['ids'] : array();
882
883 if (empty($ids)) {
884 return new \WP_REST_Response(array(
885 'success' => false,
886 'message' => __('No submissions specified.', 'superb-blocks'),
887 ), 400);
888 }
889
890 // Collect affected form IDs before deleting
891 $affected_form_ids = array();
892 foreach ($ids as $id) {
893 $fid = get_post_meta(intval($id), '_spb_form_id', true);
894 if ($fid) {
895 $affected_form_ids[sanitize_key($fid)] = true;
896 }
897 }
898
899 $deleted = FormSubmissionHandler::BulkDelete($ids);
900
901 // Clean up pending_delete forms that may now have zero submissions
902 foreach (array_keys($affected_form_ids) as $fid) {
903 FormRegistry::CleanupAfterSubmissionDelete($fid);
904 }
905
906 return rest_ensure_response(array(
907 'success' => true,
908 'deleted' => $deleted,
909 ));
910 }
911
912 /**
913 * Bulk update submission status (read/unread).
914 */
915 public static function BulkUpdateStatusCallback($request)
916 {
917 $params = $request->get_json_params();
918 $ids = isset($params['ids']) && is_array($params['ids']) ? $params['ids'] : array();
919 $status = isset($params['status']) ? sanitize_text_field($params['status']) : '';
920
921 if (empty($ids) || !in_array($status, array('read', 'new'), true)) {
922 return new \WP_REST_Response(array(
923 'success' => false,
924 'message' => __('Invalid request.', 'superb-blocks'),
925 ), 400);
926 }
927
928 $updated = FormSubmissionHandler::BulkUpdateStatus($ids, $status);
929 return rest_ensure_response(array(
930 'success' => true,
931 'updated' => $updated,
932 ));
933 }
934
935 /**
936 * Star a submission.
937 */
938 public static function StarSubmissionCallback($request)
939 {
940 $id = intval($request['id']);
941 $result = FormSubmissionHandler::Star($id);
942
943 if ($result) {
944 return rest_ensure_response(array('success' => true));
945 }
946
947 return new \WP_REST_Response(array(
948 'success' => false,
949 'message' => __('Submission not found.', 'superb-blocks'),
950 ), 404);
951 }
952
953 /**
954 * Unstar a submission.
955 */
956 public static function UnstarSubmissionCallback($request)
957 {
958 $id = intval($request['id']);
959 $result = FormSubmissionHandler::Unstar($id);
960
961 if ($result) {
962 return rest_ensure_response(array('success' => true));
963 }
964
965 return new \WP_REST_Response(array(
966 'success' => false,
967 'message' => __('Submission not found.', 'superb-blocks'),
968 ), 404);
969 }
970
971 /**
972 * Bulk star/unstar submissions.
973 */
974 public static function BulkStarCallback($request)
975 {
976 $params = $request->get_json_params();
977 $ids = isset($params['ids']) && is_array($params['ids']) ? $params['ids'] : array();
978 $star = isset($params['star']) ? (bool) $params['star'] : true;
979
980 if (empty($ids)) {
981 return new \WP_REST_Response(array(
982 'success' => false,
983 'message' => __('No submissions specified.', 'superb-blocks'),
984 ), 400);
985 }
986
987 $updated = FormSubmissionHandler::BulkStar($ids, $star);
988 return rest_ensure_response(array(
989 'success' => true,
990 'updated' => $updated,
991 ));
992 }
993
994 /**
995 * Mark a submission as read.
996 */
997 public static function MarkSubmissionReadCallback($request)
998 {
999 $id = intval($request['id']);
1000 $result = FormSubmissionHandler::MarkAsRead($id);
1001
1002 if ($result) {
1003 return rest_ensure_response(array('success' => true));
1004 }
1005
1006 return new \WP_REST_Response(array(
1007 'success' => false,
1008 'message' => __('Submission not found.', 'superb-blocks'),
1009 ), 404);
1010 }
1011
1012 /**
1013 * Mark a submission as unread.
1014 */
1015 public static function MarkSubmissionUnreadCallback($request)
1016 {
1017 $id = intval($request['id']);
1018 $result = FormSubmissionHandler::MarkAsUnread($id);
1019
1020 if ($result) {
1021 return rest_ensure_response(array('success' => true));
1022 }
1023
1024 return new \WP_REST_Response(array(
1025 'success' => false,
1026 'message' => __('Submission not found.', 'superb-blocks'),
1027 ), 404);
1028 }
1029
1030 /**
1031 * Delete a submission.
1032 */
1033 public static function DeleteSubmissionCallback($request)
1034 {
1035 $id = intval($request['id']);
1036 $form_id = get_post_meta($id, '_spb_form_id', true);
1037 $deleted = FormSubmissionHandler::Delete($id);
1038
1039 if ($deleted) {
1040 if ($form_id) {
1041 FormRegistry::CleanupAfterSubmissionDelete(sanitize_key($form_id));
1042 }
1043 return rest_ensure_response(array('success' => true));
1044 }
1045
1046 return new \WP_REST_Response(array(
1047 'success' => false,
1048 'message' => __('Submission not found.', 'superb-blocks'),
1049 ), 404);
1050 }
1051
1052 /**
1053 * Delete all data for a form (submissions, registry entry, config).
1054 */
1055 public static function DeleteFormCallback($request)
1056 {
1057 $form_id = sanitize_key($request['form_id']);
1058
1059 if (empty($form_id)) {
1060 return new \WP_REST_Response(array(
1061 'success' => false,
1062 'message' => __('Invalid form ID.', 'superb-blocks'),
1063 ), 400);
1064 }
1065
1066 // FORM_DELETE_ROUTE is '/form/(?P<form_id>[a-zA-Z0-9_-]+)' and overlaps with
1067 // sibling endpoints like '/form/submissions', '/form/fields', etc. Reject
1068 // reserved path segments so a DELETE to those never silently runs here.
1069 $reserved = array('submissions', 'fields', 'integrations', 'captcha', 'webhook');
1070 if (in_array($form_id, $reserved, true)) {
1071 return new \WP_REST_Response(array(
1072 'success' => false,
1073 'message' => __('Invalid form ID.', 'superb-blocks'),
1074 ), 400);
1075 }
1076
1077 // Optionally remove the form block from its source post
1078 $params = $request->get_json_params();
1079 $block_removed = false;
1080 if (!empty($params['remove_block'])) {
1081 $block_removed = FormRegistry::RemoveFormBlock($form_id);
1082 }
1083
1084 $deleted = FormSubmissionHandler::DeleteAllByFormId($form_id);
1085 FormRegistry::Remove($form_id);
1086 delete_option(FormRegistry::CONFIG_PREFIX . $form_id);
1087
1088 return rest_ensure_response(array(
1089 'success' => true,
1090 'deleted_submissions' => $deleted,
1091 'block_removed' => $block_removed,
1092 ));
1093 }
1094
1095 /**
1096 * Fetch Mailchimp lists/audiences.
1097 */
1098 public static function GetMailchimpListsCallback()
1099 {
1100 $result = FormIntegrationHandler::GetMailchimpLists();
1101 if (is_wp_error($result)) {
1102 $status = 400;
1103 $error_data = $result->get_error_data();
1104 if (isset($error_data['status'])) {
1105 $status = intval($error_data['status']);
1106 }
1107 return new \WP_REST_Response(array(
1108 'success' => false,
1109 'code' => $result->get_error_code(),
1110 'message' => $result->get_error_message(),
1111 ), $status);
1112 }
1113 return rest_ensure_response(array('lists' => $result));
1114 }
1115
1116 /**
1117 * Fetch Brevo lists.
1118 */
1119 public static function GetBrevoListsCallback()
1120 {
1121 $result = FormIntegrationHandler::GetBrevoLists();
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 * Check whether captcha API keys are configured.
1139 */
1140 public static function GetCaptchaStatusCallback($request)
1141 {
1142 $type = isset($request['type']) ? sanitize_text_field($request['type']) : '';
1143
1144 $key_map = array(
1145 'hcaptcha' => array(FormSettings::OPTION_HCAPTCHA_SITE_KEY, FormSettings::OPTION_HCAPTCHA_SECRET_KEY),
1146 'recaptcha_v2' => array(FormSettings::OPTION_RECAPTCHA_SITE_KEY, FormSettings::OPTION_RECAPTCHA_SECRET_KEY),
1147 'recaptcha_v3' => array(FormSettings::OPTION_RECAPTCHA_SITE_KEY, FormSettings::OPTION_RECAPTCHA_SECRET_KEY),
1148 'turnstile' => array(FormSettings::OPTION_TURNSTILE_SITE_KEY, FormSettings::OPTION_TURNSTILE_SECRET_KEY),
1149 );
1150
1151 if (!isset($key_map[$type])) {
1152 return new \WP_REST_Response(array(
1153 'success' => false,
1154 'code' => 'invalid_type',
1155 'message' => __('Invalid captcha type.', 'superb-blocks'),
1156 ), 400);
1157 }
1158
1159 $keys = $key_map[$type];
1160 $site_key = FormSettings::Get($keys[0]);
1161 $secret_key = FormSettings::Get($keys[1]);
1162
1163 if (empty($site_key) || empty($secret_key)) {
1164 return new \WP_REST_Response(array(
1165 'success' => false,
1166 'code' => 'no_api_key',
1167 'message' => __('API keys are not configured for this method.', 'superb-blocks'),
1168 ), 400);
1169 }
1170
1171 return rest_ensure_response(array('success' => true));
1172 }
1173
1174 /**
1175 * Mark a spam submission as "Not Spam" (rescue to regular submissions).
1176 */
1177 public static function NotSpamCallback($request)
1178 {
1179 $id = intval($request['id']);
1180 $result = FormSubmissionHandler::MarkNotSpam($id);
1181
1182 if ($result) {
1183 return rest_ensure_response(array('success' => true));
1184 }
1185
1186 return new \WP_REST_Response(array(
1187 'success' => false,
1188 'message' => __('Submission not found or is not spam.', 'superb-blocks'),
1189 ), 404);
1190 }
1191
1192 /**
1193 * Get the spam counter for a form.
1194 */
1195 public static function GetSpamCountCallback($request)
1196 {
1197 $form_id = sanitize_key($request['form_id']);
1198 return rest_ensure_response(array(
1199 'spam_count' => FormSubmissionHandler::GetSpamCount($form_id),
1200 'spam_submission_count' => FormSubmissionHandler::GetSpamSubmissionCount($form_id),
1201 ));
1202 }
1203
1204 /**
1205 * Retry an integration (Mailchimp or Brevo) for an existing submission.
1206 */
1207 public static function RetryIntegrationCallback($request)
1208 {
1209 $id = intval($request['id']);
1210 $params = $request->get_json_params();
1211 $integration = isset($params['integration']) ? sanitize_text_field($params['integration']) : '';
1212
1213 if (!in_array($integration, array('mailchimp', 'brevo'), true)) {
1214 return new \WP_REST_Response(array(
1215 'success' => false,
1216 'message' => __('Invalid integration.', 'superb-blocks'),
1217 ), 400);
1218 }
1219
1220 $post = get_post($id);
1221 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
1222 return new \WP_REST_Response(array(
1223 'success' => false,
1224 'message' => __('Submission not found.', 'superb-blocks'),
1225 ), 404);
1226 }
1227
1228 $form_id = get_post_meta($id, '_spb_form_id', true);
1229 $form_data = self::GetFormConfig($form_id);
1230 if ($form_data === null) {
1231 return new \WP_REST_Response(array(
1232 'success' => false,
1233 'message' => __('Form configuration not found.', 'superb-blocks'),
1234 ), 404);
1235 }
1236
1237 $fields = get_post_meta($id, '_spb_form_fields', true);
1238 if (!is_array($fields)) {
1239 $fields = array();
1240 }
1241 $fields = self::DecryptSubmissionFields($form_data['form_fields'], $fields);
1242
1243 $email = self::FindSubmissionEmail($fields, $form_data['form_fields']);
1244
1245 if (empty($email)) {
1246 return new \WP_REST_Response(array(
1247 'success' => false,
1248 'message' => __('No email address found in submission fields.', 'superb-blocks'),
1249 ), 400);
1250 }
1251
1252 $result = false;
1253 $error_message = '';
1254
1255 if ($integration === 'mailchimp') {
1256 if (empty($form_data['mailchimp_enabled']) || empty($form_data['mailchimp_list_ids'])) {
1257 return new \WP_REST_Response(array(
1258 'success' => false,
1259 'message' => __('Mailchimp is not enabled for this form.', 'superb-blocks'),
1260 ), 400);
1261 }
1262 $result = FormIntegrationHandler::SendToMailchimp($form_data['mailchimp_list_ids'], $email, $fields);
1263 } elseif ($integration === 'brevo') {
1264 if (empty($form_data['brevo_enabled']) || empty($form_data['brevo_list_ids'])) {
1265 return new \WP_REST_Response(array(
1266 'success' => false,
1267 'message' => __('Brevo is not enabled for this form.', 'superb-blocks'),
1268 ), 400);
1269 }
1270 $result = FormIntegrationHandler::SendToBrevo($form_data['brevo_list_ids'], $email, $fields);
1271 }
1272
1273 // Store integration status meta
1274 $status_meta = get_post_meta($id, '_spb_form_integration_status', true);
1275 if (!is_array($status_meta)) {
1276 $status_meta = array();
1277 }
1278 $status_meta[$integration] = array(
1279 'sent' => (bool) $result,
1280 'time' => time(),
1281 'error' => $result ? null : __('Integration request failed.', 'superb-blocks'),
1282 );
1283 update_post_meta($id, '_spb_form_integration_status', $status_meta);
1284
1285 if ($result) {
1286 return rest_ensure_response(array('success' => true));
1287 }
1288
1289 return new \WP_REST_Response(array(
1290 'success' => false,
1291 'message' => __('Failed to send to integration. Please try again.', 'superb-blocks'),
1292 ), 500);
1293 }
1294
1295 // ========================================
1296 // Phase 3: Notes
1297 // ========================================
1298
1299 /**
1300 * Get notes for a submission.
1301 */
1302 public static function GetNotesCallback($request)
1303 {
1304 $id = intval($request['id']);
1305 $notes = FormSubmissionHandler::GetNotes($id);
1306 return rest_ensure_response(array(
1307 'notes' => $notes,
1308 'note_count' => count($notes),
1309 ));
1310 }
1311
1312 /**
1313 * Add a note to a submission.
1314 */
1315 public static function AddNoteCallback($request)
1316 {
1317 $id = intval($request['id']);
1318 $params = $request->get_json_params();
1319 $text = isset($params['text']) ? $params['text'] : '';
1320
1321 if (empty($text)) {
1322 return new \WP_REST_Response(array(
1323 'success' => false,
1324 'message' => __('Note text is required.', 'superb-blocks'),
1325 ), 400);
1326 }
1327
1328 if (mb_strlen($text) > 1000) {
1329 return new \WP_REST_Response(array(
1330 'success' => false,
1331 'message' => __('Note must be 1000 characters or fewer.', 'superb-blocks'),
1332 ), 400);
1333 }
1334
1335 $current_user = wp_get_current_user();
1336 $note = FormSubmissionHandler::AddNote(
1337 $id,
1338 $current_user->ID,
1339 $current_user->display_name,
1340 $text
1341 );
1342
1343 if ($note === false) {
1344 return new \WP_REST_Response(array(
1345 'success' => false,
1346 'message' => __('Failed to add note.', 'superb-blocks'),
1347 ), 400);
1348 }
1349
1350 return rest_ensure_response(array(
1351 'success' => true,
1352 'note' => $note,
1353 'notes' => FormSubmissionHandler::GetNotes($id),
1354 'note_count' => FormSubmissionHandler::GetNoteCount($id),
1355 ));
1356 }
1357
1358 /**
1359 * Delete a note from a submission.
1360 */
1361 public static function DeleteNoteCallback($request)
1362 {
1363 $id = intval($request['id']);
1364 $index = intval($request['index']);
1365 $current_user = wp_get_current_user();
1366
1367 $result = FormSubmissionHandler::DeleteNote($id, $index, $current_user->ID);
1368
1369 if (!$result) {
1370 return new \WP_REST_Response(array(
1371 'success' => false,
1372 'message' => __('Failed to delete note.', 'superb-blocks'),
1373 ), 400);
1374 }
1375
1376 return rest_ensure_response(array(
1377 'success' => true,
1378 'notes' => FormSubmissionHandler::GetNotes($id),
1379 'note_count' => FormSubmissionHandler::GetNoteCount($id),
1380 ));
1381 }
1382
1383 // ========================================
1384 // Phase 3: Field Preferences
1385 // ========================================
1386
1387 /**
1388 * Save field preferences for the current user.
1389 */
1390 public static function SaveFieldsCallback($request)
1391 {
1392 $params = $request->get_json_params();
1393 $form_id = isset($params['form_id']) ? sanitize_key($params['form_id']) : '';
1394 $fields = isset($params['fields']) && is_array($params['fields']) ? $params['fields'] : array();
1395
1396 if (empty($form_id)) {
1397 return new \WP_REST_Response(array(
1398 'success' => false,
1399 'message' => __('Invalid form ID.', 'superb-blocks'),
1400 ), 400);
1401 }
1402
1403 if (empty($fields)) {
1404 return new \WP_REST_Response(array(
1405 'success' => false,
1406 'message' => __('At least one field is required.', 'superb-blocks'),
1407 ), 400);
1408 }
1409
1410 $user_id = get_current_user_id();
1411 $result = FormSubmissionHandler::SaveFieldPreference($user_id, $form_id, $fields);
1412
1413 return rest_ensure_response(array(
1414 'success' => $result,
1415 ));
1416 }
1417
1418 /**
1419 * Get field preferences for the current user.
1420 */
1421 public static function GetFieldsCallback($request)
1422 {
1423 $form_id = sanitize_key($request['form_id']);
1424 $user_id = get_current_user_id();
1425 $fields = FormSubmissionHandler::GetFieldPreference($user_id, $form_id);
1426
1427 return rest_ensure_response(array(
1428 'fields' => $fields,
1429 ));
1430 }
1431
1432 /**
1433 * Read form configuration from server-side storage.
1434 * The config is stored as an option during save_post (FormRegistry) and block render (EnqueueForm).
1435 * This ensures all config comes from the database, not from client-supplied data.
1436 */
1437 private static function GetFormConfig($form_id)
1438 {
1439 $attrs = FormRegistry::GetConfig($form_id);
1440 if (empty($attrs) || !is_array($attrs)) {
1441 return null;
1442 }
1443
1444 return array(
1445 'form_id' => $form_id,
1446 'form_name' => isset($attrs['formName']) ? sanitize_text_field($attrs['formName']) : '',
1447 'captcha_type' => isset($attrs['captchaType']) ? sanitize_text_field($attrs['captchaType']) : 'honeypot',
1448 'honeypot_key' => isset($attrs['honeypotKey']) ? sanitize_text_field($attrs['honeypotKey']) : '',
1449 'email_enabled' => !empty($attrs['emailEnabled']),
1450 'store_enabled' => isset($attrs['storeEnabled']) ? (bool) $attrs['storeEnabled'] : false,
1451 'email_to' => isset($attrs['emailTo']) ? sanitize_text_field($attrs['emailTo']) : '',
1452 'email_subject' => isset($attrs['emailSubject']) ? sanitize_text_field($attrs['emailSubject']) : '',
1453 'email_reply_to' => isset($attrs['emailReplyTo']) ? sanitize_text_field($attrs['emailReplyTo']) : '',
1454 'email_cc' => isset($attrs['emailCC']) ? sanitize_text_field($attrs['emailCC']) : '',
1455 'email_bcc' => isset($attrs['emailBCC']) ? sanitize_text_field($attrs['emailBCC']) : '',
1456 'send_confirmation' => isset($attrs['sendConfirmation']) ? (bool) $attrs['sendConfirmation'] : false,
1457 'confirmation_subject' => isset($attrs['confirmationSubject']) ? sanitize_text_field($attrs['confirmationSubject']) : '',
1458 'confirmation_message' => isset($attrs['confirmationMessage']) ? sanitize_textarea_field($attrs['confirmationMessage']) : '',
1459 'confirmation_email_field' => isset($attrs['confirmationEmailField']) ? sanitize_text_field($attrs['confirmationEmailField']) : '',
1460 'success_behavior' => isset($attrs['successBehavior']) ? sanitize_text_field($attrs['successBehavior']) : 'message',
1461 'redirect_url' => isset($attrs['redirectUrl']) ? esc_url_raw($attrs['redirectUrl']) : '',
1462 'mailchimp_enabled' => isset($attrs['mailchimpEnabled']) ? (bool) $attrs['mailchimpEnabled'] : false,
1463 'mailchimp_list_ids' => isset($attrs['mailchimpListIds']) && is_array($attrs['mailchimpListIds'])
1464 ? array_map('sanitize_text_field', $attrs['mailchimpListIds'])
1465 : array(),
1466 'brevo_enabled' => isset($attrs['brevoEnabled']) ? (bool) $attrs['brevoEnabled'] : false,
1467 'brevo_list_ids' => isset($attrs['brevoListIds']) && is_array($attrs['brevoListIds'])
1468 ? array_map('intval', $attrs['brevoListIds'])
1469 : array(),
1470 'form_fields' => isset($attrs['formFields']) && is_array($attrs['formFields']) ? $attrs['formFields'] : array(),
1471 'store_spam_enabled' => isset($attrs['storeSpamEnabled']) ? (bool) $attrs['storeSpamEnabled'] : false,
1472 // Webhook
1473 'webhook_enabled' => isset($attrs['webhookEnabled']) ? (bool) $attrs['webhookEnabled'] : false,
1474 'webhook_url' => isset($attrs['webhookUrl']) ? esc_url_raw($attrs['webhookUrl']) : '',
1475 'webhook_method' => isset($attrs['webhookMethod']) ? sanitize_text_field($attrs['webhookMethod']) : 'POST',
1476 'webhook_secret' => FormSettings::GetWebhookSecret($form_id),
1477 'webhook_headers' => isset($attrs['webhookHeaders']) && is_array($attrs['webhookHeaders']) ? $attrs['webhookHeaders'] : array(),
1478 // Google Sheets
1479 'google_sheets_enabled' => isset($attrs['googleSheetsEnabled']) ? (bool) $attrs['googleSheetsEnabled'] : false,
1480 'google_sheets_spreadsheet_url' => isset($attrs['googleSheetsSpreadsheetUrl']) ? sanitize_text_field($attrs['googleSheetsSpreadsheetUrl']) : '',
1481 'google_sheets_sheet_name' => isset($attrs['googleSheetsSheetName']) ? sanitize_text_field($attrs['googleSheetsSheetName']) : '',
1482 // Slack
1483 'slack_enabled' => isset($attrs['slackEnabled']) ? (bool) $attrs['slackEnabled'] : false,
1484 'slack_webhook_url' => isset($attrs['slackWebhookUrl']) ? esc_url_raw($attrs['slackWebhookUrl']) : '',
1485 );
1486 }
1487
1488 /**
1489 * Find the submission's primary email for integration delivery.
1490 * Prefers fields explicitly typed as 'email' in the form config; falls back to
1491 * the first string that passes is_email() so legacy forms without typing still work.
1492 */
1493 private static function FindSubmissionEmail($fields, $form_fields)
1494 {
1495 if (is_array($form_fields)) {
1496 foreach ($form_fields as $fc) {
1497 if (!isset($fc['fieldType'], $fc['fieldId'])) {
1498 continue;
1499 }
1500 if ($fc['fieldType'] !== 'email') {
1501 continue;
1502 }
1503 $fid = $fc['fieldId'];
1504 if (isset($fields[$fid]) && is_string($fields[$fid]) && is_email($fields[$fid])) {
1505 return $fields[$fid];
1506 }
1507 }
1508 }
1509 foreach ($fields as $value) {
1510 if (is_string($value) && is_email($value)) {
1511 return $value;
1512 }
1513 }
1514 return '';
1515 }
1516
1517 /**
1518 * Decrypt sensitive fields in a submission's field data.
1519 *
1520 * @param array $form_fields Array of field definitions (with fieldId/sensitive flags).
1521 * @param array $fields Submission field data (field_id => value).
1522 * @param bool $pending_delete Whether the form is pending deletion (config unavailable).
1523 * @return array The fields array with sensitive values decrypted.
1524 */
1525 private static function DecryptSubmissionFields($form_fields, $fields, $pending_delete = false)
1526 {
1527 if (empty($form_fields) && $pending_delete) {
1528 // Config is gone — detect sensitive fields by encryption prefix
1529 foreach ($fields as $fid => $value) {
1530 if (FormEncryption::IsEncrypted($value)) {
1531 $decrypted = FormEncryption::Decrypt($value);
1532 if ($decrypted !== false) {
1533 $fields[$fid] = $decrypted;
1534 }
1535 }
1536 }
1537 return $fields;
1538 }
1539
1540 foreach ($form_fields as $field_def) {
1541 if (!empty($field_def['sensitive']) && !empty($field_def['fieldId'])) {
1542 $sfid = $field_def['fieldId'];
1543 if (isset($fields[$sfid]) && is_string($fields[$sfid])) {
1544 $decrypted = FormEncryption::Decrypt($fields[$sfid]);
1545 if ($decrypted !== false) {
1546 $fields[$sfid] = $decrypted;
1547 }
1548 }
1549 }
1550 }
1551 return $fields;
1552 }
1553
1554 /**
1555 * Serve a file from a submission (admin-only).
1556 */
1557 public static function ServeFileCallback($request)
1558 {
1559 $post_id = intval($request['id']);
1560 $field_id = sanitize_text_field($request['field_id']);
1561 $index = intval($request['index']);
1562
1563 $post = get_post($post_id);
1564 if (!$post || $post->post_type !== FormSubmissionCPT::POST_TYPE) {
1565 return new \WP_REST_Response(array(
1566 'success' => false,
1567 'message' => __('Submission not found.', 'superb-blocks'),
1568 ), 404);
1569 }
1570
1571 $fields = get_post_meta($post_id, '_spb_form_fields', true);
1572 if (!is_array($fields) || !isset($fields[$field_id]) || !is_array($fields[$field_id])) {
1573 return new \WP_REST_Response(array(
1574 'success' => false,
1575 'message' => __('File not found.', 'superb-blocks'),
1576 ), 404);
1577 }
1578
1579 $file_list = $fields[$field_id];
1580 if (!isset($file_list[$index]) || !is_array($file_list[$index])) {
1581 return new \WP_REST_Response(array(
1582 'success' => false,
1583 'message' => __('File not found.', 'superb-blocks'),
1584 ), 404);
1585 }
1586
1587 $file_meta = $file_list[$index];
1588 $file_path = isset($file_meta['path']) ? $file_meta['path'] : '';
1589 $original_name = isset($file_meta['name']) ? $file_meta['name'] : 'download';
1590 $mime_type = isset($file_meta['type']) && $file_meta['type'] !== '' ? $file_meta['type'] : 'application/octet-stream';
1591
1592 return FormFileHandler::ServeFile($file_path, $original_name, $mime_type);
1593 }
1594
1595 /**
1596 * Process email list integrations.
1597 *
1598 * @param array $form_data
1599 * @param array $fields
1600 * @param int $post_id Submission post ID for status tracking
1601 */
1602 private static function ProcessIntegrations($form_data, $fields, $post_id = 0)
1603 {
1604 $integration_status = array();
1605 $form_fields_config = isset($form_data['form_fields']) ? $form_data['form_fields'] : array();
1606
1607 // Webhook (no email required)
1608 if (!empty($form_data['webhook_enabled']) && !empty($form_data['webhook_url'])) {
1609 $result = FormIntegrationHandler::SendWebhook(
1610 $form_data['webhook_url'],
1611 $form_data['webhook_method'],
1612 $form_data['form_id'],
1613 $form_data['form_name'],
1614 $fields,
1615 $form_fields_config,
1616 isset($form_data['webhook_secret']) ? $form_data['webhook_secret'] : '',
1617 isset($form_data['webhook_headers']) ? $form_data['webhook_headers'] : array()
1618 );
1619 $integration_status['webhook'] = array(
1620 'sent' => !empty($result['sent']),
1621 'time' => time(),
1622 'code' => isset($result['code']) ? $result['code'] : 0,
1623 'error' => isset($result['error']) ? $result['error'] : null,
1624 );
1625 }
1626
1627 // Google Sheets (no email required)
1628 if (!empty($form_data['google_sheets_enabled']) && !empty($form_data['google_sheets_spreadsheet_url'])) {
1629 $result = FormIntegrationHandler::SendToGoogleSheets(
1630 $form_data['google_sheets_spreadsheet_url'],
1631 isset($form_data['google_sheets_sheet_name']) ? $form_data['google_sheets_sheet_name'] : '',
1632 $fields,
1633 $form_fields_config
1634 );
1635 $integration_status['google_sheets'] = array(
1636 'sent' => !empty($result['sent']),
1637 'time' => time(),
1638 'error' => isset($result['error']) ? $result['error'] : null,
1639 );
1640 }
1641
1642 // Slack (no email required)
1643 if (!empty($form_data['slack_enabled']) && !empty($form_data['slack_webhook_url'])) {
1644 $result = FormIntegrationHandler::SendToSlack(
1645 $form_data['slack_webhook_url'],
1646 $form_data['form_name'],
1647 $fields,
1648 $form_fields_config
1649 );
1650 $integration_status['slack'] = array(
1651 'sent' => !empty($result['sent']),
1652 'time' => time(),
1653 'error' => isset($result['error']) ? $result['error'] : null,
1654 );
1655 }
1656
1657 // Find email from submitted fields (required for Mailchimp/Brevo)
1658 $email = self::FindSubmissionEmail($fields, $form_fields_config);
1659
1660 if (!empty($email)) {
1661 // Mailchimp
1662 if (!empty($form_data['mailchimp_enabled']) && !empty($form_data['mailchimp_list_ids'])) {
1663 $result = FormIntegrationHandler::SendToMailchimp(
1664 $form_data['mailchimp_list_ids'],
1665 $email,
1666 $fields
1667 );
1668 $integration_status['mailchimp'] = array(
1669 'sent' => (bool) $result,
1670 'time' => time(),
1671 'error' => $result ? null : __('Mailchimp request failed.', 'superb-blocks'),
1672 );
1673 }
1674
1675 // Brevo
1676 if (!empty($form_data['brevo_enabled']) && !empty($form_data['brevo_list_ids'])) {
1677 $result = FormIntegrationHandler::SendToBrevo(
1678 $form_data['brevo_list_ids'],
1679 $email,
1680 $fields
1681 );
1682 $integration_status['brevo'] = array(
1683 'sent' => (bool) $result,
1684 'time' => time(),
1685 'error' => $result ? null : __('Brevo request failed.', 'superb-blocks'),
1686 );
1687 }
1688 }
1689
1690 // Store integration status on submission
1691 if ($post_id > 0 && !empty($integration_status)) {
1692 update_post_meta($post_id, '_spb_form_integration_status', $integration_status);
1693 }
1694 }
1695
1696 /**
1697 * Webhook test endpoint callback.
1698 */
1699 public static function WebhookTestCallback($request)
1700 {
1701 $url = isset($request['url']) ? esc_url_raw($request['url']) : '';
1702 $method = isset($request['method']) ? sanitize_text_field($request['method']) : 'POST';
1703 $secret = isset($request['secret']) ? sanitize_text_field($request['secret']) : '';
1704 $headers = isset($request['headers']) && is_array($request['headers']) ? $request['headers'] : array();
1705
1706 if (empty($url)) {
1707 return new \WP_REST_Response(array(
1708 'success' => false,
1709 'status_code' => 0,
1710 'error' => __('URL is required.', 'superb-blocks'),
1711 ), 400);
1712 }
1713
1714 $validated_url = wp_http_validate_url($url);
1715 if (!$validated_url) {
1716 return new \WP_REST_Response(array(
1717 'success' => false,
1718 'status_code' => 0,
1719 'error' => __('Invalid or blocked URL.', 'superb-blocks'),
1720 ), 400);
1721 }
1722 $url = $validated_url;
1723
1724 $test_fields = array(
1725 'test_field' => array(
1726 'label' => 'Name',
1727 'value' => 'Test Submission',
1728 'type' => 'text',
1729 ),
1730 'test_email' => array(
1731 'label' => 'Email',
1732 'value' => 'test@example.com',
1733 'type' => 'email',
1734 ),
1735 );
1736
1737 $payload = array(
1738 'form_id' => 'test',
1739 'form_name' => 'Test Form',
1740 'submitted_at' => gmdate('c'),
1741 'test' => true,
1742 'fields' => $test_fields,
1743 );
1744
1745 $json = wp_json_encode($payload);
1746 if ($json === false) {
1747 return new \WP_REST_Response(array(
1748 'success' => false,
1749 'status_code' => 0,
1750 'error' => __('Failed to encode test payload.', 'superb-blocks'),
1751 ), 500);
1752 }
1753
1754 $request_headers = array(
1755 'Content-Type' => 'application/json',
1756 'User-Agent' => 'SuperbAddons/' . SUPERBADDONS_VERSION,
1757 );
1758
1759 if (!empty($secret)) {
1760 $request_headers['X-Superb-Signature'] = 'sha256=' . hash_hmac('sha256', $json, $secret);
1761 }
1762
1763 if (is_array($headers)) {
1764 foreach ($headers as $h) {
1765 if (!empty($h['key'])) {
1766 $request_headers[sanitize_text_field($h['key'])] = sanitize_text_field(isset($h['value']) ? $h['value'] : '');
1767 }
1768 }
1769 }
1770
1771 $allowed_methods = array('POST', 'PUT', 'PATCH');
1772 if (!in_array(strtoupper($method), $allowed_methods, true)) {
1773 $method = 'POST';
1774 }
1775
1776 $response = wp_remote_request($url, array(
1777 'method' => strtoupper($method),
1778 'headers' => $request_headers,
1779 'body' => $json,
1780 'timeout' => 15,
1781 ));
1782
1783 if (is_wp_error($response)) {
1784 return rest_ensure_response(array(
1785 'success' => false,
1786 'status_code' => 0,
1787 'error' => $response->get_error_message(),
1788 ));
1789 }
1790
1791 $code = wp_remote_retrieve_response_code($response);
1792 return rest_ensure_response(array(
1793 'success' => $code >= 200 && $code < 300,
1794 'status_code' => $code,
1795 'error' => ($code >= 200 && $code < 300) ? null : sprintf('HTTP %d', $code),
1796 ));
1797 }
1798
1799 /**
1800 * Google Sheets status endpoint callback.
1801 */
1802 public static function GoogleSheetsStatusCallback()
1803 {
1804 $client_email = FormSettings::Get(FormSettings::OPTION_GOOGLE_SHEETS_CLIENT_EMAIL);
1805 $configured = !empty($client_email) && FormSettings::HasValue(FormSettings::OPTION_GOOGLE_SHEETS_PRIVATE_KEY);
1806
1807 return rest_ensure_response(array(
1808 'configured' => $configured,
1809 'client_email' => $configured ? $client_email : '',
1810 ));
1811 }
1812
1813 /**
1814 * Google Sheets test connection endpoint callback.
1815 */
1816 public static function GoogleSheetsTestCallback($request)
1817 {
1818 $spreadsheet_url = isset($request['spreadsheet_url']) ? sanitize_text_field($request['spreadsheet_url']) : '';
1819 $sheet_name = isset($request['sheet_name']) ? sanitize_text_field($request['sheet_name']) : '';
1820
1821 if (empty($spreadsheet_url)) {
1822 return rest_ensure_response(array(
1823 'success' => false,
1824 'error' => __('Spreadsheet URL is required.', 'superb-blocks'),
1825 ));
1826 }
1827
1828 // Extract spreadsheet ID from URL
1829 $spreadsheet_id = $spreadsheet_url;
1830 if (preg_match('/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/', $spreadsheet_url, $matches)) {
1831 $spreadsheet_id = $matches[1];
1832 }
1833
1834 $token = FormGoogleAuth::GetAccessToken();
1835 if (is_wp_error($token)) {
1836 return rest_ensure_response(array(
1837 'success' => false,
1838 'error' => $token->get_error_message(),
1839 ));
1840 }
1841
1842 $range = !empty($sheet_name) ? $sheet_name : 'Sheet1';
1843 // Single-quote the sheet name for A1 notation and rawurlencode the path segment, matching
1844 // SendToGoogleSheets so the test exercises the same range the live append will use.
1845 $quoted_sheet = "'" . str_replace("'", "''", $range) . "'";
1846 $url = 'https://sheets.googleapis.com/v4/spreadsheets/' . rawurlencode($spreadsheet_id) . '/values/' . rawurlencode($quoted_sheet . '!A1');
1847
1848 $response = wp_remote_get($url, array(
1849 'headers' => array('Authorization' => 'Bearer ' . $token),
1850 'timeout' => 15,
1851 ));
1852
1853 if (is_wp_error($response)) {
1854 return rest_ensure_response(array(
1855 'success' => false,
1856 'error' => $response->get_error_message(),
1857 ));
1858 }
1859
1860 $code = wp_remote_retrieve_response_code($response);
1861 if ($code >= 200 && $code < 300) {
1862 return rest_ensure_response(array('success' => true, 'error' => null));
1863 }
1864
1865 $body = json_decode(wp_remote_retrieve_body($response), true);
1866 $msg = isset($body['error']['message']) ? $body['error']['message'] : sprintf('HTTP %d', $code);
1867 return rest_ensure_response(array('success' => false, 'error' => $msg));
1868 }
1869
1870 /**
1871 * Webhook secret endpoint: GET (status), POST (save), DELETE (remove).
1872 */
1873 public static function WebhookSecretCallback($request)
1874 {
1875 $form_id = isset($request['form_id']) ? sanitize_key($request['form_id']) : '';
1876 if (empty($form_id)) {
1877 return new \WP_REST_Response(array('success' => false, 'error' => 'Missing form_id.'), 400);
1878 }
1879
1880 $method = $request->get_method();
1881
1882 if ($method === 'GET') {
1883 return rest_ensure_response(array(
1884 'has_secret' => FormSettings::HasWebhookSecret($form_id),
1885 ));
1886 }
1887
1888 if ($method === 'DELETE') {
1889 FormSettings::RemoveWebhookSecret($form_id);
1890 return rest_ensure_response(array('success' => true));
1891 }
1892
1893 // POST: save secret
1894 $secret = isset($request['secret']) ? sanitize_text_field($request['secret']) : '';
1895 if (empty($secret)) {
1896 return new \WP_REST_Response(array('success' => false, 'error' => __('Secret cannot be empty.', 'superb-blocks')), 400);
1897 }
1898
1899 FormSettings::SetWebhookSecret($form_id, $secret);
1900 return rest_ensure_response(array('success' => true));
1901 }
1902
1903 /**
1904 * Slack test endpoint callback.
1905 */
1906 public static function SlackTestCallback($request)
1907 {
1908 $webhook_url = isset($request['url']) ? esc_url_raw($request['url']) : '';
1909
1910 if (empty($webhook_url)) {
1911 return new \WP_REST_Response(array(
1912 'success' => false,
1913 'error' => __('Webhook URL is required.', 'superb-blocks'),
1914 ), 400);
1915 }
1916
1917 $validated_url = wp_http_validate_url($webhook_url);
1918 if (!$validated_url) {
1919 return new \WP_REST_Response(array(
1920 'success' => false,
1921 'error' => __('Invalid or blocked URL.', 'superb-blocks'),
1922 ), 400);
1923 }
1924
1925 $test_fields = array(
1926 'test_name' => 'Jane Smith',
1927 'test_email' => 'test@example.com',
1928 );
1929 $test_config = array(
1930 array('fieldId' => 'test_name', 'label' => 'Name', 'fieldType' => 'text'),
1931 array('fieldId' => 'test_email', 'label' => 'Email', 'fieldType' => 'email'),
1932 );
1933
1934 $result = FormIntegrationHandler::SendToSlack(
1935 $validated_url,
1936 __('Test Form', 'superb-blocks'),
1937 $test_fields,
1938 $test_config
1939 );
1940
1941 return rest_ensure_response(array(
1942 'success' => !empty($result['sent']),
1943 'error' => isset($result['error']) ? $result['error'] : null,
1944 ));
1945 }
1946 }
1947