PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.15
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.15
6.2.15 6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 All 197 releases
fluentform / app / Services / Transfer / TransferService.php

TransferService.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.15, at app/Services/Transfer/TransferService.php

841 lines 32.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Services\Transfer;
4
5 defined('ABSPATH') or die;
6
7 use FluentForm\App\Services\FormBuilder\AutocompleteTokens;
8 use Exception;
9 use FluentForm\App\Helpers\Helper;
10 use FluentForm\App\Models\Form;
11 use FluentForm\App\Models\FormMeta;
12 use FluentForm\App\Models\Submission;
13 use FluentForm\App\Models\SubmissionMeta;
14 use FluentForm\App\Modules\Acl\Acl;
15 use FluentForm\App\Modules\Form\FormDataParser;
16 use FluentForm\App\Modules\Form\FormFieldsParser;
17 use FluentForm\App\Services\FormBuilder\ShortCodeParser;
18 use FluentForm\App\Services\FormBuilder\DateConfigPolicy;
19 use FluentForm\Framework\Foundation\App;
20 use FluentForm\Framework\Http\Request\File;
21 use FluentForm\Framework\Support\Arr;
22
23 class TransferService
24 {
25 public static function sanitizeImportedMetaValue($metaKey, $metaValue)
26 {
27 if (!is_string($metaValue)) {
28 return $metaValue;
29 }
30
31 if ($metaKey === '_custom_form_css') {
32 return fluentformSanitizeCSS($metaValue);
33 }
34
35 if ($metaKey === '_custom_form_js') {
36 return fluentform_kses_js($metaValue);
37 }
38
39 $decoded = json_decode($metaValue);
40 if (is_array($decoded) || is_object($decoded)) {
41 self::sanitizeJsonNode($decoded);
42 $encoded = wp_json_encode($decoded);
43 return $encoded ?: $metaValue;
44 }
45
46 return wp_kses_post($metaValue);
47 }
48
49 private static function sanitizeJsonNode(&$node)
50 {
51 if (is_array($node)) {
52 foreach ($node as $key => &$value) {
53 if ('attributes' === $key) {
54 $value = self::dropEventHandlerAttributeKeys($value);
55 $value = self::sanitizeFieldAttributes($value);
56 }
57 $value = self::sanitizeAttributeControlSetting($key, $value);
58 self::sanitizeJsonNode($value);
59 }
60 unset($value);
61 } elseif (is_object($node)) {
62 foreach (get_object_vars($node) as $key => $value) {
63 if ('attributes' === $key) {
64 $value = self::dropEventHandlerAttributeKeys($value);
65 $value = self::sanitizeFieldAttributes($value);
66 }
67 $value = self::sanitizeAttributeControlSetting($key, $value);
68 self::sanitizeJsonNode($value);
69 $node->{$key} = $value;
70 }
71 } elseif (is_string($node)) {
72 $node = wp_kses_post($node);
73 }
74 }
75
76 // Scoped to a field's own attributes: sanitizeJsonNode() walks the whole meta
77 // tree, so matching on key name alone would rewrite any unrelated property
78 // that happens to be called autocomplete.
79 private static function sanitizeFieldAttributes($attributes)
80 {
81 if (is_object($attributes) && property_exists($attributes, 'autocomplete')) {
82 $attributes->autocomplete = AutocompleteTokens::sanitize($attributes->autocomplete);
83 } elseif (is_array($attributes) && array_key_exists('autocomplete', $attributes)) {
84 $attributes['autocomplete'] = AutocompleteTokens::sanitize($attributes['autocomplete']);
85 }
86
87 return $attributes;
88 }
89
90 private static function sanitizeAttributeControlSetting($key, $value)
91 {
92 if ('max_repeat_field' === $key) {
93 return is_scalar($value) && '' !== trim((string) $value) ? absint($value) : '';
94 }
95
96 if ('display_mode' === $key) {
97 $mode = is_scalar($value) ? sanitize_key((string) $value) : '';
98 return in_array($mode, ['accordion', 'tabs'], true) ? $mode : 'accordion';
99 }
100
101 if ('display_type' === $key) {
102 return is_scalar($value) ? sanitize_html_class((string) $value) : '';
103 }
104
105 if ('subscription_options' === $key && is_array($value)) {
106 foreach ($value as &$option) {
107 if (!is_array($option)) {
108 continue;
109 }
110
111 foreach (['name', 'user_input_label'] as $labelKey) {
112 if (!array_key_exists($labelKey, $option)) {
113 continue;
114 }
115
116 $label = $option[$labelKey];
117 $option[$labelKey] = is_scalar($label) ? fluentform_sanitize_html((string) $label) : '';
118 }
119 }
120 unset($option);
121
122 return $value;
123 }
124
125 if ('pricing_options' !== $key || !is_array($value)) {
126 return $value;
127 }
128
129 foreach ($value as &$option) {
130 if (!is_array($option)) {
131 continue;
132 }
133
134 if (array_key_exists('label', $option)) {
135 $label = $option['label'];
136 $option['label'] = is_scalar($label) ? fluentform_sanitize_html((string) $label) : '';
137 }
138
139 if (array_key_exists('image', $option)) {
140 $image = $option['image'];
141 $option['image'] = is_scalar($image) ? esc_url_raw((string) $image) : '';
142 }
143 }
144 unset($option);
145
146 return $value;
147 }
148
149 /**
150 * kses cleans string values only, so an `onfocus` KEY survives an import untouched.
151 * Shares the Helper rule so the two write paths cannot drift apart.
152 */
153 private static function dropEventHandlerAttributeKeys($attributes)
154 {
155 if (!is_array($attributes) && !is_object($attributes)) {
156 return $attributes;
157 }
158
159 $keys = is_object($attributes)
160 ? array_keys(get_object_vars($attributes))
161 : array_keys($attributes);
162
163 foreach ($keys as $key) {
164 if (Helper::isSafeAttributeKey($key)) {
165 continue;
166 }
167
168 if (is_object($attributes)) {
169 unset($attributes->{$key});
170 } else {
171 unset($attributes[$key]);
172 }
173 }
174
175 return $attributes;
176 }
177
178 public static function exportForms($formIds)
179 {
180 $result = Form::with(['formMeta'])
181 ->whereIn('id', $formIds)
182 ->get();
183 $forms = [];
184 foreach ($result as $item) {
185 $form = json_decode($item);
186 $formMetaFiltered = array_filter($form->form_meta, function ($item) {
187 return ($item->meta_key !== '_total_views');
188 });
189 $form->metas = $formMetaFiltered;
190 $form->form_fields = json_decode($form->form_fields);
191 $forms[] = $form;
192 }
193
194 $fileName = 'fluentform-export-forms-' . count($forms) . '-' . date('d-m-Y') . '.json';
195
196 header('Content-disposition: attachment; filename=' . $fileName);
197
198 header('Content-type: application/json');
199
200 echo json_encode(array_values($forms)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $forms is escaped before being passed in.
201
202 die();
203 }
204
205 /**
206 * Build the notice shown when imported custom JS/CSS or executable date
207 * configuration was skipped because the importer lacks unfiltered_html. Returns an empty string when nothing was skipped.
208 *
209 * @param int $skippedForms
210 * @param int $totalForms
211 * @return string
212 */
213 protected static function restrictedCodeNotice($skippedForms, $totalForms)
214 {
215 if (!$skippedForms) {
216 return '';
217 }
218
219 if ($totalForms < 2) {
220 return __('Custom JS, CSS and advanced date configuration were not imported because your account cannot add custom code. Ask an administrator to add it.', 'fluentform');
221 }
222
223 return sprintf(
224 /* translators: 1: number of forms whose custom code was skipped, 2: total number of imported forms */
225 __('Custom JS, CSS and advanced date configuration were not imported for %1$d of %2$d forms because your account cannot add custom code. Ask an administrator to add it.', 'fluentform'),
226 $skippedForms,
227 $totalForms
228 );
229 }
230
231 /**
232 * @param File $file The uploaded JSON file
233 * @param bool $applyDefaultStyle Whether to apply default style settings to imported forms
234 * @throws Exception
235 */
236 public static function importForms($file, $applyDefaultStyle = false)
237 {
238 if ($file instanceof File) {
239 $forms = \json_decode($file->getContents(), true);
240 $insertedForms = [];
241 $restrictedCodeForms = 0;
242 if ($forms && is_array($forms)) {
243 foreach ($forms as $formItem) {
244 $formFields = json_encode([]);
245 if ($fields = Arr::get($formItem, 'form', '')) {
246 $formFields = json_encode($fields);
247 } elseif ($fields = Arr::get($formItem, 'form_fields', '')) {
248 $formFields = json_encode($fields);
249 } else {
250 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
251 }
252
253 // SECURITY (FINDING-07): the editor save path routes form_fields through
254 // Updater::sanitizeFields (skipped only for unfiltered_html users), but import
255 // stored them verbatim, so an importer without unfiltered_html could plant
256 // stored XSS (e.g. a field label of <img onerror=...>). Apply the same
257 // recursive HTML sanitizer used for imported meta values unless the importer
258 // may author raw HTML.
259 $droppedDateConfigs = 0;
260 if (!fluentformCanUnfilteredHTML()) {
261 $decodedFields = json_decode($formFields, true);
262 if (is_array($decodedFields)) {
263 self::sanitizeJsonNode($decodedFields);
264 $decodedFields['fields'] = DateConfigPolicy::dropExecutableConfigs(
265 Arr::get($decodedFields, 'fields', []),
266 $droppedDateConfigs
267 );
268 $formFields = wp_json_encode($decodedFields) ?: $formFields;
269 }
270 }
271
272 $formTitle = sanitize_text_field(Arr::get($formItem, 'title'));
273 $form = [
274 'title' => $formTitle ?: 'Blank Form',
275 'form_fields' => $formFields,
276 'status' => sanitize_text_field(Arr::get($formItem, 'status', 'published')),
277 'has_payment' => sanitize_text_field(Arr::get($formItem, 'has_payment', 0)),
278 'type' => sanitize_text_field(Arr::get($formItem, 'type', 'form')),
279 'created_by' => get_current_user_id(),
280 ];
281
282 if (Arr::get($formItem, 'conditions')) {
283 $form['conditions'] = Arr::get($formItem, 'conditions');
284 }
285
286 if (isset($formItem['appearance_settings'])) {
287 $form['appearance_settings'] = Arr::get($formItem, 'appearance_settings');
288 }
289
290 $formId = Form::insertGetId($form);
291 $insertedForms[$formId] = [
292 'title' => $form['title'],
293 'edit_url' => admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $formId),
294 ];
295
296 $skippedCustomCode = $droppedDateConfigs > 0;
297
298 if (isset($formItem['metas'])) {
299 foreach ($formItem['metas'] as $metaData) {
300 $metaKey = sanitize_text_field(Arr::get($metaData, 'meta_key'));
301 $metaValue = Arr::get($metaData, 'value');
302 // SECURITY (FINDING-08): Customizer::store() refuses to save custom
303 // JS/CSS without unfiltered_html; import must honor the same boundary.
304 // Sanitizing _custom_form_js via fluentform_kses_js is insufficient
305 // because the value is JS *code* executed inside a <script> block (kses
306 // only strips <script> tags), so skip these keys entirely for importers
307 // who cannot author raw JS/CSS.
308 if (
309 in_array($metaKey, ['_custom_form_js', '_custom_form_css'], true)
310 && !fluentformCanUnfilteredHTML()
311 ) {
312 $skippedCustomCode = true;
313 continue;
314 }
315 if ('ffc_form_settings_generated_css' == $metaKey || 'ffc_form_settings_meta' == $metaKey) {
316 $metaValue = str_replace('ff_conv_app_' . Arr::get($formItem, 'id'), 'ff_conv_app_' . $formId, $metaValue);
317 }
318 $metaValue = static::sanitizeImportedMetaValue($metaKey, $metaValue);
319 $settings = [
320 'form_id' => $formId,
321 'meta_key' => $metaKey,
322 'value' => $metaValue,
323 ];
324 FormMeta::insert($settings);
325 }
326 } else {
327 $oldKeys = [
328 'formSettings',
329 'notifications',
330 'mailchimp_feeds',
331 'slack',
332 ];
333 foreach ($oldKeys as $key) {
334 if (isset($formItem[$key])) {
335 FormMeta::persist($formId, $key, json_encode(Arr::get($formItem, $key)));
336 }
337 }
338 }
339
340 if ($skippedCustomCode) {
341 $restrictedCodeForms++;
342 }
343
344 do_action('fluentform/form_imported', $formId);
345
346 // Apply default style if requested
347 if ($applyDefaultStyle) {
348 do_action('fluentform/inserted_new_form', $formId, $form);
349 }
350 }
351
352 return ([
353 'message' => __('You form has been successfully imported.', 'fluentform'),
354 'inserted_forms' => $insertedForms,
355 'restricted_code_notice' => static::restrictedCodeNotice($restrictedCodeForms, count($insertedForms)),
356 ]);
357 }
358 }
359 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
360 }
361
362 public static function exportEntries($args)
363 {
364 if (!defined('FLUENTFORM_EXPORTING_ENTRIES')) {
365 define('FLUENTFORM_EXPORTING_ENTRIES', true);
366 }
367
368 $formId = Acl::verifyFormId(Arr::get($args, 'form_id'));
369 Acl::verify('fluentform_entries_viewer', $formId);
370
371 $tableName = Arr::get($args, 'table');
372 try {
373 $form = Form::findOrFail($formId);
374 } catch (Exception $e) {
375 exit('No Form Found');
376 }
377 $type = sanitize_key(Arr::get($args, 'format', 'csv'));
378 if (!in_array($type, ['csv', 'ods', 'xlsx', 'json'])) {
379 exit('Invalid requested format');
380 }
381 self::markDownloadStarted(Arr::get($args, 'download_token'));
382 if ('json' == $type) {
383 self::exportAsJSON($form, $args);
384 }
385 if (!defined('FLUENTFORM_DOING_CSV_EXPORT')) {
386 define('FLUENTFORM_DOING_CSV_EXPORT', true);
387 }
388 $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'raw']);
389 $inputLabels = FormFieldsParser::getAdminLabels($form, $formInputs);
390 $selectedLabels = Arr::get($args, 'fields_to_export');
391 if (is_string($selectedLabels) && Helper::isJson($selectedLabels)) {
392 $selectedLabels = \json_decode($selectedLabels, true);
393 }
394 $selectedLabels = fluentFormSanitizer($selectedLabels);
395
396 $withNotes = isset($args['with_notes']);
397
398 //filter out unselected fields
399 if (!empty($selectedLabels)) {
400 foreach ($inputLabels as $key => $value) {
401 if (!in_array($key, $selectedLabels) && isset($inputLabels[$key])) {
402 unset($inputLabels[$key]);
403 }
404 }
405 }
406
407 $submissions = self::getSubmissions($args);
408 $submissions = FormDataParser::parseFormEntries($submissions, $form, $formInputs);
409 $parsedShortCodes = [];
410 $exportData = [];
411 $selectedShortcodes = self::getSelectedExportShortcodes($args, $form);
412 $legacyShortcodeHeaders = self::getLegacyExportShortcodeHeaders();
413
414 // Preload notes for all submissions in a single query to avoid N+1
415 $notesMap = [];
416 if ($withNotes && count($submissions)) {
417 $submissionIds = array_map(function ($s) {
418 return is_object($s) ? $s->id : $s['id'];
419 }, $submissions->toArray());
420 $allNotes = SubmissionMeta::whereIn('response_id', $submissionIds)
421 ->where('meta_key', '_notes')
422 ->get();
423 foreach ($allNotes as $note) {
424 $notesMap[$note->response_id][] = $note->value;
425 }
426 }
427
428 foreach ($submissions as $submission) {
429
430 $submission->response = json_decode($submission->response, true);
431
432 $temp = [];
433 foreach ($inputLabels as $field => $label) {
434
435 //format tabular grid data for CSV/XLSV/ODS export
436 if (isset($formInputs[$field]['element']) && 'tabular_grid' === $formInputs[$field]['element']) {
437 $gridRawData = Arr::get($submission->response, $field);
438 $content = Helper::getTabularGridFormatValue($gridRawData, Arr::get($formInputs, $field), ' | ');
439 } elseif (isset($formInputs[$field]['element']) && 'subscription_payment_component' === $formInputs[$field]['element']) {
440 //resolve plane name for subscription field
441 $planIndex = Arr::get($submission->user_inputs, $field);
442 $planLabel = Arr::get($formInputs, "{$field}.raw.settings.subscription_options.{$planIndex}.name");
443 if ($planLabel) {
444 $content = $planLabel;
445 } else {
446 $content = self::getFieldExportContent($submission, $field);
447 }
448 } else {
449 $content = self::getFieldExportContent($submission, $field);
450 if (Arr::get($formInputs, $field . '.element') === 'input_number' && is_numeric($content)) {
451 $content = $content + 0;
452 }
453 }
454 $temp[] = Helper::sanitizeForCSV($content);
455 }
456
457 if (!empty($selectedShortcodes)) {
458 $regularShortcodes = self::getRegularExportShortcodes($selectedShortcodes, $legacyShortcodeHeaders);
459
460 if (!empty($regularShortcodes)) {
461 $parsedShortCodes = ShortCodeParser::parse(
462 $regularShortcodes,
463 $submission->id,
464 $submission->response,
465 $form,
466 false,
467 true
468 );
469 }
470
471 // SECURITY (FINDING-17): shortcode-export values (which include submitter-controlled
472 // {inputs.*} content) bypassed the CSV formula guard applied to regular columns.
473 // Sanitize each so a leading = - + @ etc. cannot execute when opened in a spreadsheet.
474 $shortcodeValues = self::getSelectedShortcodeExportValues(
475 $selectedShortcodes,
476 $parsedShortCodes,
477 $legacyShortcodeHeaders,
478 $submission
479 );
480 $shortcodeValues = array_map(function ($v) {
481 return is_scalar($v) ? Helper::sanitizeForCSV((string) $v) : $v;
482 }, $shortcodeValues);
483 $temp = array_merge($temp, $shortcodeValues);
484 }
485 if ($withNotes) {
486 $noteValues = isset($notesMap[$submission->id]) ? $notesMap[$submission->id] : [];
487 if (!empty($noteValues)) {
488 // SECURITY (FINDING-17): notes are submitter-influenceable and were exported raw.
489 $temp[] = Helper::sanitizeForCSV(implode(", ", $noteValues));
490 }
491 }
492
493 $temp = apply_filters('fluentform/export_entry_metadata', $temp, $submission, $form, $args);
494
495 $exportData[] = $temp;
496 }
497
498 $extraLabels = [];
499
500 $extraLabels = self::getSelectedShortcodeExportLabels(
501 $selectedShortcodes,
502 $parsedShortCodes,
503 $legacyShortcodeHeaders
504 );
505
506 $inputLabels = array_merge($inputLabels, $extraLabels);
507 if ($withNotes) {
508 $inputLabels[] = __('Notes', 'fluentform');
509 }
510 $inputLabels = apply_filters('fluentform/export_entry_metadata_labels', $inputLabels, $form, $args);
511
512 // SECURITY (FINDING-17): sanitize the header row too — field/shortcode labels can start with
513 // a formula lead character (=, +, -, @) and were exported unguarded.
514 $headerRow = array_map(function ($v) {
515 return is_scalar($v) ? Helper::sanitizeForCSV((string) $v) : $v;
516 }, array_values($inputLabels));
517 $data = array_merge([$headerRow], $exportData);
518
519 $data = apply_filters('fluentform/export_data', $data, $form, $exportData, $inputLabels);
520 $fileName = self::getReadableExportFileName($form->title);
521 self::downloadOfficeDoc($data, $type, $fileName);
522 }
523
524 private static function getFieldExportContent($submission, $fieldName)
525 {
526 return trim(
527 wp_strip_all_tags(
528 FormDataParser::formatValue(
529 Arr::get($submission->user_inputs, $fieldName)
530 )
531 )
532 );
533 }
534
535 private static function getSelectedExportShortcodes($args, $form)
536 {
537 $selectedShortcodes = fluentFormSanitizer(Arr::get($args, 'shortcodes_to_export', []));
538
539 if (!Arr::has($args, 'shortcodes_to_export_defined') && empty($selectedShortcodes)) {
540 return self::getDefaultExportShortcodes($form);
541 }
542
543 return $selectedShortcodes;
544 }
545
546 private static function getDefaultExportShortcodes($form)
547 {
548 $defaults = [
549 [
550 'label' => __('Submission ID', 'fluentform'),
551 'value' => '{submission.id}',
552 ],
553 [
554 'label' => __('Submission Create Date', 'fluentform'),
555 'value' => '{submission.created_at}',
556 ],
557 [
558 'label' => __('Submission Status', 'fluentform'),
559 'value' => '{submission.status}',
560 ],
561 ];
562
563 if ($form->has_payment) {
564 $defaults[] = [
565 'label' => __('Payment Status', 'fluentform'),
566 'value' => '{payment.payment_status}',
567 ];
568 $defaults[] = [
569 'label' => __('Payment Total', 'fluentform'),
570 'value' => '{payment.payment_total}',
571 ];
572 $defaults[] = [
573 'label' => __('Currency', 'fluentform'),
574 'value' => '{submission.currency}',
575 ];
576 }
577
578 return $defaults;
579 }
580
581 private static function getLegacyExportShortcodeHeaders()
582 {
583 return [
584 '{submission.id}' => 'entry_id',
585 '{submission.status}' => 'entry_status',
586 '{submission.created_at}' => 'created_at',
587 '{payment.payment_status}' => 'payment_status',
588 '{submission.payment_status}' => 'payment_status',
589 '{payment.payment_total}' => 'payment_total',
590 '{submission.payment_total}' => 'payment_total',
591 '{submission.currency}' => 'currency',
592 ];
593 }
594
595 private static function getRegularExportShortcodes($selectedShortcodes, $legacyShortcodeHeaders)
596 {
597 $regularShortcodes = [];
598
599 foreach ($selectedShortcodes as $index => $shortcode) {
600 if (!isset($legacyShortcodeHeaders[Arr::get($shortcode, 'value')])) {
601 $regularShortcodes[$index] = $shortcode;
602 }
603 }
604
605 return $regularShortcodes;
606 }
607
608 private static function getSelectedShortcodeExportValues($selectedShortcodes, $parsedShortCodes, $legacyShortcodeHeaders, $submission)
609 {
610 $values = [];
611
612 foreach ($selectedShortcodes as $index => $shortcode) {
613 $shortcodeValue = Arr::get($shortcode, 'value');
614
615 if (!isset($legacyShortcodeHeaders[$shortcodeValue])) {
616 $values[] = Arr::get($parsedShortCodes, $index . '.value');
617 continue;
618 }
619
620 $values[] = self::getLegacyExportValue($legacyShortcodeHeaders[$shortcodeValue], $submission);
621 }
622
623 return $values;
624 }
625
626 private static function getSelectedShortcodeExportLabels($selectedShortcodes, $parsedShortCodes, $legacyShortcodeHeaders)
627 {
628 $labels = [];
629
630 foreach ($selectedShortcodes as $index => $shortcode) {
631 $shortcodeValue = Arr::get($shortcode, 'value');
632
633 if (isset($legacyShortcodeHeaders[$shortcodeValue])) {
634 $labels[] = $legacyShortcodeHeaders[$shortcodeValue];
635 continue;
636 }
637
638 $labels[] = Arr::get($parsedShortCodes, $index . '.label');
639 }
640
641 return $labels;
642 }
643
644 private static function getLegacyExportValue($header, $submission)
645 {
646 $legacyValueResolvers = [
647 'entry_id' => function ($submission) {
648 return $submission->id ?? '';
649 },
650 'entry_status' => function ($submission) {
651 return $submission->status ?? '';
652 },
653 'created_at' => function ($submission) {
654 return $submission->created_at ?? '';
655 },
656 'payment_status' => function ($submission) {
657 return $submission->payment_status ?? '';
658 },
659 'payment_total' => function ($submission) {
660 return round(($submission->payment_total ?? 0) / 100, 1);
661 },
662 'currency' => function ($submission) {
663 return $submission->currency ?? '';
664 },
665 ];
666
667 if (!isset($legacyValueResolvers[$header])) {
668 return '';
669 }
670
671 return $legacyValueResolvers[$header]($submission);
672 }
673
674 private static function exportAsJSON($form, $args)
675 {
676 $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'raw']);
677 $submissions = self::getSubmissions($args);
678 $submissions = FormDataParser::parseFormEntries($submissions, $form, $formInputs);
679 foreach ($submissions as $submission) {
680 $submission->response = json_decode($submission->response, true);
681 }
682 self::sendDownloadHeaders('application/json', self::getReadableExportFileName($form->title) . '.json');
683 echo json_encode($submissions); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $submissions is escaped before being passed in.
684 exit();
685 }
686
687 private static function getReadableExportFileName($formTitle)
688 {
689 $sanitizedTitle = sanitize_file_name(wp_strip_all_tags((string) $formTitle));
690
691 if (!$sanitizedTitle) {
692 $sanitizedTitle = 'export';
693 }
694
695 return $sanitizedTitle . '-' . date('Y-m-d');
696 }
697
698 /**
699 * Set a short-lived cookie the admin page polls to know the download has started.
700 *
701 * @param string|null $token
702 * @return void
703 */
704 private static function markDownloadStarted($token)
705 {
706 $token = substr(sanitize_key((string) $token), 0, 32);
707
708 if (!$token || headers_sent()) {
709 return;
710 }
711
712 setcookie('ff_export_' . $token, '1', [
713 'expires' => time() + 60,
714 'path' => '/',
715 'secure' => is_ssl(),
716 'samesite' => 'Lax',
717 ]);
718 }
719
720 private static function sendDownloadHeaders($contentType, $fileName)
721 {
722 $safeFileName = basename((string) $fileName);
723 $encodedFileName = rawurlencode($safeFileName);
724
725 header('Content-Type: ' . $contentType);
726 header(
727 'Content-Disposition: attachment; ' .
728 'filename="' . $encodedFileName . '"; ' .
729 'filename*=UTF-8\'\'' . $encodedFileName
730 );
731 }
732
733 private static function getSubmissions($args)
734 {
735 $tableName = Arr::get($args, 'table');
736
737 if ($tableName) {
738 $allowedTables = [
739 'fluentform_submissions',
740 'fluentform_draft_submissions',
741 ];
742 if (!in_array($tableName, $allowedTables, true)) {
743 wp_send_json([
744 'message' => __('Invalid table name for export.', 'fluentform'),
745 ], 422);
746 }
747 $query = wpFluent()->table($tableName)
748 ->where('form_id', (int) Arr::get($args, 'form_id'))
749 ->orderBy('id', Helper::sanitizeOrderValue(Arr::get($args, 'sort_by', 'DESC')));
750
751 $searchString = Arr::get($args, 'search');
752 if ($searchString) {
753 global $wpdb;
754 $escaped = $wpdb->esc_like($searchString);
755 $query->where(function ($q) use ($escaped) {
756 $q->where('id', 'LIKE', "%{$escaped}%")
757 ->orWhere('response', 'LIKE', "%{$escaped}%");
758 });
759 }
760 } else {
761 $query = (new Submission())->customQuery($args);
762 }
763
764 $entries = fluentFormSanitizer(Arr::get($args, 'entries', []));
765 $query->when(is_array($entries) && (count($entries) > 0), function ($q) use ($entries) {
766 return $q->whereIn('id', $entries);
767 });
768
769 if (Arr::get($args, 'advanced_filter')) {
770 $query = apply_filters('fluentform/apply_entries_advance_filter', $query, $args);
771 }
772
773 return $query->get();
774 }
775
776 private static function downloadOfficeDoc($data, $type = 'csv', $fileName = null)
777 {
778 $data = array_map(function ($item) {
779 return array_map(function ($itemValue) {
780 if (is_array($itemValue)) {
781 $itemValue = implode(', ', $itemValue);
782 }
783
784 return is_string($itemValue)
785 ? Helper::sanitizeForCSV($itemValue)
786 : $itemValue;
787 }, $item);
788 }, $data);
789 // Load Composer autoloader for OpenSpout
790 require_once FLUENTFORM_DIR_PATH . '/vendor/autoload.php';
791 $fileName = ($fileName) ? $fileName . '.' . $type : 'export-data-' . date('d-m-Y') . '.' . $type;
792
793 // Create writer based on type
794 switch (strtolower($type)) {
795 case 'csv':
796 $writer = \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createCSVWriter();
797 break;
798 case 'xlsx':
799 $writer = \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createXLSXWriter();
800 break;
801 case 'ods':
802 $writer = \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createODSWriter();
803 break;
804 default:
805 throw new \Exception(sprintf('Unsupported file type: %s', esc_html($type)));
806 }
807 $writer->openToBrowser($fileName);
808
809 $rows = self::getOfficeDocRows($data, $type);
810
811 $writer->addRows($rows);
812 $writer->close();
813 die();
814 }
815
816 private static function getOfficeDocRows($data, $type)
817 {
818 if (strtolower($type) !== 'xlsx') {
819 return array_map(function ($rowData) {
820 return \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createRowFromArray($rowData);
821 }, $data);
822 }
823
824 $dateStyle = (new \OpenSpout\Writer\Common\Creator\Style\StyleBuilder())
825 ->setFormat('yyyy-mm-dd hh:mm:ss')
826 ->build();
827
828 return array_map(function ($rowData) use ($dateStyle) {
829 $cells = array_map(function ($cellValue) use ($dateStyle) {
830 if ($cellValue instanceof \DateTimeInterface) {
831 return \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createCell($cellValue, $dateStyle);
832 }
833
834 return \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createCell($cellValue);
835 }, $rowData);
836
837 return \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createRow($cells);
838 }, $data);
839 }
840 }
841