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