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
← All changes | app/Services/Transfer/TransferService.php +346 -44 6.2.2 → 6.2.15 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentForm\App\Services\Transfer;
4 4
5 5 defined('ABSPATH') or die;
6 6
7 +use FluentForm\App\Services\FormBuilder\AutocompleteTokens;
7 8 use Exception;
8 9 use FluentForm\App\Helpers\Helper;
9 10 use FluentForm\App\Models\Form;
10 11 use FluentForm\App\Models\FormMeta;
@@ -13,8 +14,9 @@
13 14 use FluentForm\App\Modules\Acl\Acl;
14 15 use FluentForm\App\Modules\Form\FormDataParser;
15 16 use FluentForm\App\Modules\Form\FormFieldsParser;
16 17 use FluentForm\App\Services\FormBuilder\ShortCodeParser;
18 +use FluentForm\App\Services\FormBuilder\DateConfigPolicy;
17 19 use FluentForm\Framework\Foundation\App;
18 20 use FluentForm\Framework\Http\Request\File;
19 21 use FluentForm\Framework\Support\Arr;
20 22
@@ -19,8 +21,161 @@
19 21 use FluentForm\Framework\Support\Arr;
20 22
21 23 class TransferService
22 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 +
23 178 public static function exportForms($formIds)
24 179 {
25 180 $result = Form::with(['formMeta'])
26 181 ->whereIn('id', $formIds)
@@ -34,9 +189,9 @@
34 189 $form->metas = $formMetaFiltered;
35 190 $form->form_fields = json_decode($form->form_fields);
36 191 $forms[] = $form;
37 192 }
38 -
193 +
39 194 $fileName = 'fluentform-export-forms-' . count($forms) . '-' . date('d-m-Y') . '.json';
40 195
41 196 header('Content-disposition: attachment; filename=' . $fileName);
42 197
@@ -47,8 +202,34 @@
47 202 die();
48 203 }
49 204
50 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 + /**
51 232 * @param File $file The uploaded JSON file
52 233 * @param bool $applyDefaultStyle Whether to apply default style settings to imported forms
53 234 * @throws Exception
54 235 */
@@ -56,8 +237,9 @@
56 237 {
57 238 if ($file instanceof File) {
58 239 $forms = \json_decode($file->getContents(), true);
59 240 $insertedForms = [];
241 + $restrictedCodeForms = 0;
60 242 if ($forms && is_array($forms)) {
61 243 foreach ($forms as $formItem) {
62 244 $formFields = json_encode([]);
63 245 if ($fields = Arr::get($formItem, 'form', '')) {
@@ -66,8 +248,28 @@
66 248 $formFields = json_encode($fields);
67 249 } else {
68 250 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
69 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 +
70 272 $formTitle = sanitize_text_field(Arr::get($formItem, 'title'));
71 273 $form = [
72 274 'title' => $formTitle ?: 'Blank Form',
73 275 'form_fields' => $formFields,
@@ -90,18 +292,31 @@
90 292 'title' => $form['title'],
91 293 'edit_url' => admin_url('admin.php?page=fluent_forms&route=editor&form_id=' . $formId),
92 294 ];
93 295
296 + $skippedCustomCode = $droppedDateConfigs > 0;
297 +
94 298 if (isset($formItem['metas'])) {
95 299 foreach ($formItem['metas'] as $metaData) {
96 300 $metaKey = sanitize_text_field(Arr::get($metaData, 'meta_key'));
97 301 $metaValue = Arr::get($metaData, 'value');
98 - if ("ffc_form_settings_generated_css" == $metaKey || "ffc_form_settings_meta" == $metaKey) {
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) {
99 316 $metaValue = str_replace('ff_conv_app_' . Arr::get($formItem, 'id'), 'ff_conv_app_' . $formId, $metaValue);
100 317 }
101 - if (is_string($metaValue)) {
102 - $metaValue = wp_kses_post($metaValue);
103 - }
318 + $metaValue = static::sanitizeImportedMetaValue($metaKey, $metaValue);
104 319 $settings = [
105 320 'form_id' => $formId,
106 321 'meta_key' => $metaKey,
107 322 'value' => $metaValue,
@@ -121,8 +336,12 @@
121 336 }
122 337 }
123 338 }
124 339
340 + if ($skippedCustomCode) {
341 + $restrictedCodeForms++;
342 + }
343 +
125 344 do_action('fluentform/form_imported', $formId);
126 345
127 346 // Apply default style if requested
128 347 if ($applyDefaultStyle) {
@@ -130,10 +349,11 @@
130 349 }
131 350 }
132 351
133 352 return ([
134 - 'message' => __('You form has been successfully imported.', 'fluentform'),
135 - 'inserted_forms' => $insertedForms,
353 + 'message' => __('You form has been successfully imported.', 'fluentform'),
354 + 'inserted_forms' => $insertedForms,
355 + 'restricted_code_notice' => static::restrictedCodeNotice($restrictedCodeForms, count($insertedForms)),
136 356 ]);
137 357 }
138 358 }
139 359 throw new Exception(esc_html__('You have a faulty JSON file, please export the Fluent Forms again.', 'fluentform'));
@@ -157,8 +377,9 @@
157 377 $type = sanitize_key(Arr::get($args, 'format', 'csv'));
158 378 if (!in_array($type, ['csv', 'ods', 'xlsx', 'json'])) {
159 379 exit('Invalid requested format');
160 380 }
381 + self::markDownloadStarted(Arr::get($args, 'download_token'));
161 382 if ('json' == $type) {
162 383 self::exportAsJSON($form, $args);
163 384 }
164 385 if (!defined('FLUENTFORM_DOING_CSV_EXPORT')) {
@@ -165,16 +386,16 @@
165 386 define('FLUENTFORM_DOING_CSV_EXPORT', true);
166 387 }
167 388 $formInputs = FormFieldsParser::getEntryInputs($form, ['admin_label', 'raw']);
168 389 $inputLabels = FormFieldsParser::getAdminLabels($form, $formInputs);
169 - $selectedLabels = Arr::get($args,'fields_to_export');
390 + $selectedLabels = Arr::get($args, 'fields_to_export');
170 391 if (is_string($selectedLabels) && Helper::isJson($selectedLabels)) {
171 392 $selectedLabels = \json_decode($selectedLabels, true);
172 393 }
173 394 $selectedLabels = fluentFormSanitizer($selectedLabels);
174 -
395 +
175 396 $withNotes = isset($args['with_notes']);
176 -
397 +
177 398 //filter out unselected fields
178 399 if (!empty($selectedLabels)) {
179 400 foreach ($inputLabels as $key => $value) {
180 401 if (!in_array($key, $selectedLabels) && isset($inputLabels[$key])) {
@@ -181,9 +402,9 @@
181 402 unset($inputLabels[$key]);
182 403 }
183 404 }
184 405 }
185 -
406 +
186 407 $submissions = self::getSubmissions($args);
187 408 $submissions = FormDataParser::parseFormEntries($submissions, $form, $formInputs);
188 409 $parsedShortCodes = [];
189 410 $exportData = [];
@@ -192,9 +413,11 @@
192 413
193 414 // Preload notes for all submissions in a single query to avoid N+1
194 415 $notesMap = [];
195 416 if ($withNotes && count($submissions)) {
196 - $submissionIds = array_map(function ($s) { return is_object($s) ? $s->id : $s['id']; }, $submissions->toArray());
417 + $submissionIds = array_map(function ($s) {
418 + return is_object($s) ? $s->id : $s['id'];
419 + }, $submissions->toArray());
197 420 $allNotes = SubmissionMeta::whereIn('response_id', $submissionIds)
198 421 ->where('meta_key', '_notes')
199 422 ->get();
200 423 foreach ($allNotes as $note) {
@@ -204,20 +427,20 @@
204 427
205 428 foreach ($submissions as $submission) {
206 429
207 430 $submission->response = json_decode($submission->response, true);
208 -
431 +
209 432 $temp = [];
210 433 foreach ($inputLabels as $field => $label) {
211 -
434 +
212 435 //format tabular grid data for CSV/XLSV/ODS export
213 - if (isset($formInputs[$field]['element']) && "tabular_grid" === $formInputs[$field]['element']) {
436 + if (isset($formInputs[$field]['element']) && 'tabular_grid' === $formInputs[$field]['element']) {
214 437 $gridRawData = Arr::get($submission->response, $field);
215 438 $content = Helper::getTabularGridFormatValue($gridRawData, Arr::get($formInputs, $field), ' | ');
216 - } elseif (isset($formInputs[$field]['element']) && "subscription_payment_component" === $formInputs[$field]['element']) {
439 + } elseif (isset($formInputs[$field]['element']) && 'subscription_payment_component' === $formInputs[$field]['element']) {
217 440 //resolve plane name for subscription field
218 441 $planIndex = Arr::get($submission->user_inputs, $field);
219 - $planLabel = Arr::get($formInputs, "{$field}.raw.settings.subscription_options.{$planIndex}.name");
442 + $planLabel = Arr::get($formInputs, "{$field}.raw.settings.subscription_options.{$planIndex}.name");
220 443 if ($planLabel) {
221 444 $content = $planLabel;
222 445 } else {
223 446 $content = self::getFieldExportContent($submission, $field);
@@ -223,15 +446,15 @@
223 446 $content = self::getFieldExportContent($submission, $field);
224 447 }
225 448 } else {
226 449 $content = self::getFieldExportContent($submission, $field);
227 - if (Arr::get($formInputs, $field . '.element') === "input_number" && is_numeric($content)) {
450 + if (Arr::get($formInputs, $field . '.element') === 'input_number' && is_numeric($content)) {
228 451 $content = $content + 0;
229 452 }
230 453 }
231 454 $temp[] = Helper::sanitizeForCSV($content);
232 455 }
233 -
456 +
234 457 if (!empty($selectedShortcodes)) {
235 458 $regularShortcodes = self::getRegularExportShortcodes($selectedShortcodes, $legacyShortcodeHeaders);
236 459
237 460 if (!empty($regularShortcodes)) {
@@ -244,22 +467,27 @@
244 467 true
245 468 );
246 469 }
247 470
248 - $temp = array_merge(
249 - $temp,
250 - self::getSelectedShortcodeExportValues(
251 - $selectedShortcodes,
252 - $parsedShortCodes,
253 - $legacyShortcodeHeaders,
254 - $submission
255 - )
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
256 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);
257 484 }
258 485 if ($withNotes) {
259 486 $noteValues = isset($notesMap[$submission->id]) ? $notesMap[$submission->id] : [];
260 487 if (!empty($noteValues)) {
261 - $temp[] = implode(", ", $noteValues);
488 + // SECURITY (FINDING-17): notes are submitter-influenceable and were exported raw.
489 + $temp[] = Helper::sanitizeForCSV(implode(", ", $noteValues));
262 490 }
263 491 }
264 492
265 493 $temp = apply_filters('fluentform/export_entry_metadata', $temp, $submission, $form, $args);
@@ -273,19 +501,24 @@
273 501 $selectedShortcodes,
274 502 $parsedShortCodes,
275 503 $legacyShortcodeHeaders
276 504 );
277 -
505 +
278 506 $inputLabels = array_merge($inputLabels, $extraLabels);
279 - if($withNotes){
280 - $inputLabels[] = __('Notes','fluentform');
507 + if ($withNotes) {
508 + $inputLabels[] = __('Notes', 'fluentform');
281 509 }
282 510 $inputLabels = apply_filters('fluentform/export_entry_metadata_labels', $inputLabels, $form, $args);
283 511
284 - $data = array_merge([array_values($inputLabels)], $exportData);
285 -
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 +
286 519 $data = apply_filters('fluentform/export_data', $data, $form, $exportData, $inputLabels);
287 - $fileName = sanitize_title($form->title, 'export', 'view') . '-' . date('Y-m-d');
520 + $fileName = self::getReadableExportFileName($form->title);
288 521 self::downloadOfficeDoc($data, $type, $fileName);
289 522 }
290 523
291 524 private static function getFieldExportContent($submission, $fieldName)
@@ -445,14 +678,59 @@
445 678 $submissions = FormDataParser::parseFormEntries($submissions, $form, $formInputs);
446 679 foreach ($submissions as $submission) {
447 680 $submission->response = json_decode($submission->response, true);
448 681 }
449 - header('Content-disposition: attachment; filename=' . sanitize_title($form->title, 'export', 'view') . '-' . date('Y-m-d') . '.json');
450 - header('Content-type: application/json');
682 + self::sendDownloadHeaders('application/json', self::getReadableExportFileName($form->title) . '.json');
451 683 echo json_encode($submissions); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $submissions is escaped before being passed in.
452 684 exit();
453 685 }
454 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 +
455 733 private static function getSubmissions($args)
456 734 {
457 735 $tableName = Arr::get($args, 'table');
458 736
@@ -462,9 +740,9 @@
462 740 'fluentform_draft_submissions',
463 741 ];
464 742 if (!in_array($tableName, $allowedTables, true)) {
465 743 wp_send_json([
466 - 'message' => __('Invalid table name for export.', 'fluentform')
744 + 'message' => __('Invalid table name for export.', 'fluentform'),
467 745 ], 422);
468 746 }
469 747 $query = wpFluent()->table($tableName)
470 748 ->where('form_id', (int) Arr::get($args, 'form_id'))
@@ -479,9 +757,9 @@
479 757 ->orWhere('response', 'LIKE', "%{$escaped}%");
480 758 });
481 759 }
482 760 } else {
483 - $query = (new Submission)->customQuery($args);
761 + $query = (new Submission())->customQuery($args);
484 762 }
485 763
486 764 $entries = fluentFormSanitizer(Arr::get($args, 'entries', []));
487 765 $query->when(is_array($entries) && (count($entries) > 0), function ($q) use ($entries) {
@@ -499,11 +777,14 @@
499 777 {
500 778 $data = array_map(function ($item) {
501 779 return array_map(function ($itemValue) {
502 780 if (is_array($itemValue)) {
503 - return implode(', ', $itemValue);
781 + $itemValue = implode(', ', $itemValue);
504 782 }
505 - return $itemValue;
783 +
784 + return is_string($itemValue)
785 + ? Helper::sanitizeForCSV($itemValue)
786 + : $itemValue;
506 787 }, $item);
507 788 }, $data);
508 789 // Load Composer autoloader for OpenSpout
509 790 require_once FLUENTFORM_DIR_PATH . '/vendor/autoload.php';
@@ -524,12 +805,9 @@
524 805 throw new \Exception(sprintf('Unsupported file type: %s', esc_html($type)));
525 806 }
526 807 $writer->openToBrowser($fileName);
527 808
528 - // Convert data arrays to Row objects for OpenSpout v3
529 - $rows = array_map(function ($rowData) {
530 - return \OpenSpout\Writer\Common\Creator\WriterEntityFactory::createRowFromArray($rowData);
531 - }, $data);
809 + $rows = self::getOfficeDocRows($data, $type);
532 810
533 811 $writer->addRows($rows);
534 812 $writer->close();
535 813 die();
@@ -534,5 +812,29 @@
534 812 $writer->close();
535 813 die();
536 814 }
537 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 + }
538 840 }