PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
bit-form / includes / Core / Util / FileHandler.php

FileHandler.php in Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder V3.3.2, at includes/Core/Util/FileHandler.php

1,162 lines 38.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BitCode\BitForm\Core\Util;
4
5 use BitCode\BitForm\Admin\Form\Helpers;
6 use BitCode\BitForm\Core\Form\FormManager;
7 use BitCode\BitForm\enshrined\svgSanitize\Sanitizer;
8
9 if (!defined('ABSPATH')) {
10 exit;
11 }
12 final class FileHandler
13 {
14 public function rmrf($dir)
15 {
16 $fs = self::initWpFilesystem();
17 if ($fs && $fs->exists($dir)) {
18 $fs->delete($dir, true);
19 }
20 }
21
22 public function cpyr($source, $destination)
23 {
24 if (is_dir($source)) {
25 wp_mkdir_p($destination);
26 // chmod($destination, 0744);
27 $objects = scandir($source);
28 foreach ($objects as $object) {
29 if ('.' !== $object && '..' !== $object) {
30 if (is_dir($source . DIRECTORY_SEPARATOR . $object) && !is_link($source . DIRECTORY_SEPARATOR . $object)) {
31 cpyr($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
32 } elseif (is_file($source . DIRECTORY_SEPARATOR . $object)) {
33 copy($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
34 // chmod($destination. DIRECTORY_SEPARATOR .$object, 0644);
35 } else {
36 symlink($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object);
37 }
38 }
39 }
40 } else {
41 copy($source, $destination);
42 }
43 }
44
45 public function moveUploadedFiles($file_details, $form_id, $entry_id)
46 {
47 require_once ABSPATH . 'wp-admin/includes/file.php';
48
49 $file_upoalded = [];
50 $_upload_dir = self::getEntriesFileUploadDir($form_id, $entry_id);
51 $_upload_url = self::getEntriesFileUploadURL($form_id, $entry_id);
52 $this::createIndexFile($_upload_dir);
53
54 $upload_dir_filter = function ($uploads) use ($_upload_dir, $_upload_url) {
55 $uploads['path'] = $_upload_dir;
56 $uploads['url'] = $_upload_url;
57 $uploads['subdir'] = '';
58 $uploads['basedir'] = dirname($_upload_dir);
59 $uploads['baseurl'] = dirname($_upload_url);
60 return $uploads;
61 };
62
63 if (is_array($file_details['name'])) {
64 foreach ($file_details['name'] as $key => $value) {
65 if (!empty($value)) {
66 $fileNameCount = 1;
67 $file_upoalded[$key] = sanitize_file_name($value);
68 while (file_exists($_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[$key])) {
69 $fileNameWithSeparator = BITFORMS_BF_SEPARATOR . $fileNameCount;
70 $file_upoalded[$key] = sanitize_file_name(preg_replace('/(.[a-z A-Z 0-9]+)$/', "{$fileNameWithSeparator}$1", $value));
71 $fileNameCount = $fileNameCount + 1;
72 if (11 === $fileNameCount) {
73 break;
74 }
75 }
76 $file = [
77 'name' => $file_upoalded[$key],
78 'type' => $file_details['type'][$key] ?? '',
79 'tmp_name' => $file_details['tmp_name'][$key],
80 'error' => $file_details['error'][$key] ?? 0,
81 'size' => $file_details['size'][$key] ?? 0,
82 ];
83 add_filter('upload_dir', $upload_dir_filter);
84 $upload_result = wp_handle_upload($file, ['test_form' => false]);
85 remove_filter('upload_dir', $upload_dir_filter);
86 if (isset($upload_result['error'])) {
87 unset($file_upoalded[$key]);
88 } else {
89 $file_upoalded[$key] = basename($upload_result['file']);
90 }
91 }
92 }
93 } else {
94 if (!empty($file_details['name'])) {
95 $fileNameCount = 1;
96 $file_upoalded[0] = sanitize_file_name($file_details['name']);
97 while (file_exists($_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[0])) {
98 $fileNameWithSeparator = BITFORMS_BF_SEPARATOR . $fileNameCount;
99 $file_upoalded[0] = sanitize_file_name(preg_replace('/(.[a-z A-Z 0-9]+)$/', "{$fileNameWithSeparator}$1", $file_details['name']));
100 $fileNameCount = $fileNameCount + 1;
101 if (11 === $fileNameCount) {
102 break;
103 }
104 }
105 $file = [
106 'name' => $file_upoalded[0],
107 'type' => $file_details['type'] ?? '',
108 'tmp_name' => $file_details['tmp_name'],
109 'error' => $file_details['error'] ?? 0,
110 'size' => $file_details['size'] ?? 0,
111 ];
112 add_filter('upload_dir', $upload_dir_filter);
113 $upload_result = wp_handle_upload($file, ['test_form' => false]);
114 remove_filter('upload_dir', $upload_dir_filter);
115 if (isset($upload_result['error'])) {
116 unset($file_upoalded[0]);
117 } else {
118 $file_upoalded[0] = basename($upload_result['file']);
119 }
120 }
121 }
122 return $file_upoalded;
123 }
124
125 public static function isSafeFileName($name)
126 {
127 if (!is_string($name)) {
128 return false;
129 }
130
131 $trimmed = trim($name);
132 if ('' === $trimmed) {
133 return false;
134 }
135
136 $baseName = basename($trimmed);
137 if ('' === $baseName || $baseName !== $trimmed || 'index.php' === $baseName) {
138 return false;
139 }
140
141 return sanitize_file_name($trimmed) === $trimmed;
142 }
143
144 public function deleteFiles($form_id, $entry_id, $files)
145 {
146 $_upload_dir = self::getEntriesFileUploadDir($form_id, $entry_id);
147 $resolvedBitformsUploadDir = realpath(BITFORMS_UPLOAD_DIR);
148 $resolvedUploadDir = realpath($_upload_dir);
149 if (false === $resolvedBitformsUploadDir || false === $resolvedUploadDir) {
150 return;
151 }
152
153 $bitformsUploadDirPrefix = trailingslashit(wp_normalize_path($resolvedBitformsUploadDir));
154 $uploadDirPrefix = trailingslashit(wp_normalize_path($resolvedUploadDir));
155 if (0 !== strpos($uploadDirPrefix, $bitformsUploadDirPrefix)) {
156 return;
157 }
158
159 foreach ((array) $files as $name) {
160 if (!self::isSafeFileName($name)) {
161 continue;
162 }
163
164 $candidatePath = $resolvedUploadDir . DIRECTORY_SEPARATOR . $name;
165 $resolvedPath = realpath($candidatePath);
166 if (false === $resolvedPath || !is_file($resolvedPath)) {
167 continue;
168 }
169
170 $normalizedPath = wp_normalize_path($resolvedPath);
171 if (0 !== strpos($normalizedPath, $uploadDirPrefix)) {
172 continue;
173 }
174
175 wp_delete_file($resolvedPath);
176 }
177 }
178
179 public static function getFileUploadError($code)
180 {
181 $errors = [
182 0 => __('Unknown upload error', 'bit-form'),
183 1 => __('The uploaded file exceeds the upload_max_filesize directive in php.ini.', 'bit-form'),
184 2 => __('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', 'bit-form'),
185 3 => __('The uploaded file was only partially uploaded.', 'bit-form'),
186 4 => __('No file was uploaded.', 'bit-form'),
187 6 => __('Missing a temporary folder.', 'bit-form'),
188 7 => __('Failed to write file to disk.', 'bit-form'),
189 8 => __('A PHP extension stopped the file upload.', 'bit-form'),
190 ];
191 return $errors[$code];
192 }
193
194 public static function fileCopy($tmpdir, $destinationDir, $file)
195 {
196 $tmpBase = realpath($tmpdir);
197 $destBase = realpath($destinationDir);
198 if (false === $tmpBase || false === $destBase) {
199 Log::debug_log([
200 'message' => 'FileHandler::fileCopy base path invalid',
201 'tmpdir' => $tmpdir,
202 'destinationDir' => $destinationDir,
203 ]);
204 return;
205 }
206
207 $safeFile = is_string($file) ? trim($file) : '';
208 if ('' === $safeFile) {
209 return;
210 }
211
212 $candidate = $tmpBase . DIRECTORY_SEPARATOR . $safeFile;
213 $resolved = realpath($candidate);
214 if (false === $resolved || 0 !== strpos($resolved, $tmpBase . DIRECTORY_SEPARATOR)) {
215 Log::debug_log([
216 'message' => 'FileHandler::fileCopy blocked path traversal',
217 'file' => $file,
218 'resolved' => $resolved,
219 'tmpBase' => $tmpBase,
220 ]);
221 return;
222 }
223 if (!is_readable($resolved)) {
224 return;
225 }
226
227 $newFile = $destBase . DIRECTORY_SEPARATOR . basename($resolved);
228 copy($resolved, $newFile);
229 }
230
231 public static function tempDirToUploadDir($submitted_data, $fields, $formId, $entryID)
232 {
233 $upload_dir = wp_upload_dir();
234 $tempDir = $upload_dir['basedir'] . '/bitforms/temp';
235 $destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR;
236 self::createIndexFile($destinationDir);
237
238 $consumedFiles = [];
239
240 foreach ($submitted_data as $key => $data) {
241 if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) {
242 $fldData = $submitted_data[$key];
243 // A repeater row (or a pre-split value) hands this field over as an array; explode()
244 // on an array is a TypeError on PHP 8, which would fatal mid-submission.
245 $files = \is_array($fldData) ? $fldData : explode(',', \is_scalar($fldData) ? (string) $fldData : '');
246 foreach ($files as $file) {
247 $safeFile = \is_scalar($file) ? trim((string) $file) : '';
248 if ('' === $safeFile) {
249 continue;
250 }
251 self::fileCopy($tempDir, $destinationDir, $safeFile);
252 $consumedFiles[] = $safeFile;
253 }
254 if (!empty($files)) {
255 $submitted_data[$key] = $files;
256 }
257 }
258 }
259
260 self::cleanupTempUploads($tempDir, $consumedFiles);
261
262 return $submitted_data;
263 }
264
265 /**
266 * Clear the shared temp upload staging area after a submission has taken what it needs.
267 *
268 * @param string $tempDir shared staging directory
269 * @param array $consumedFiles file names copied into the entry directory by this submission
270 *
271 * @return void
272 */
273 private static function cleanupTempUploads($tempDir, array $consumedFiles)
274 {
275 $tempBase = realpath($tempDir);
276 if (false === $tempBase) {
277 return;
278 }
279 $boundary = $tempBase . DIRECTORY_SEPARATOR;
280
281 // Never remove the directory-hardening files the uploads dir relies on.
282 $protected = ['index.php', 'index.html', '.htaccess'];
283
284 foreach ($consumedFiles as $file) {
285 $resolved = realpath($boundary . $file);
286 if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
287 continue;
288 }
289 if (\in_array(basename($resolved), $protected, true)) {
290 continue;
291 }
292 wp_delete_file($resolved);
293 }
294
295 // Sweep abandoned uploads. Anything still here after the retention window belongs to a form
296 // that was never submitted. Filterable so a site with very long multi-step forms can extend
297 // it; 0 or less disables the sweep entirely.
298 $retention = apply_filters('bitform_temp_upload_retention', DAY_IN_SECONDS);
299 $retention = is_numeric($retention) ? (int) $retention : DAY_IN_SECONDS;
300 if ($retention <= 0) {
301 return;
302 }
303
304 $cutoff = time() - $retention;
305 $tmpFiles = glob($boundary . '*');
306 foreach ((array) $tmpFiles as $tmpFile) {
307 $resolved = realpath($tmpFile);
308 if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) {
309 continue;
310 }
311 if (\in_array(basename($resolved), $protected, true)) {
312 continue;
313 }
314 $modified = @filemtime($resolved);
315 if (false !== $modified && $modified < $cutoff) {
316 wp_delete_file($resolved);
317 }
318 }
319 }
320
321 private function getByteSizeByUnit($sizeString, $base = 1024)
322 {
323 // split 2MB into 2 and MB
324 $size = (float) preg_replace('/[^0-9\.]/', '', (string) $sizeString);
325 $unit = preg_replace('/[^a-zA-Z]/', '', (string) $sizeString);
326 $unit = strtolower($unit);
327 if ('kb' === $unit) {
328 return $size * $base;
329 } elseif ('mb' === $unit) {
330 return $size * $base * $base;
331 } elseif ('gb' === $unit) {
332 return $size * $base * $base * $base;
333 } else {
334 return $size;
335 }
336 }
337
338 /**
339 * How many files an Advanced File Upload field takes per submission, mirroring FilePond: a
340 * single-file field holds one, and maxFiles only applies once multiple files are allowed.
341 *
342 * @param object|null $config field config
343 *
344 * @return int|null null when there is no limit
345 */
346 public static function getMaxFilesLimit($config)
347 {
348 if (!is_object($config)) {
349 return null;
350 }
351 if (empty($config->allowMultiple)) {
352 return 1;
353 }
354 $max = isset($config->maxFiles) && is_numeric($config->maxFiles) ? (int) $config->maxFiles : 0;
355
356 return $max > 0 ? $max : null;
357 }
358
359 /**
360 * Files submitted for one field: names already uploaded on select (comma list or array) plus
361 * the files sent with the form.
362 *
363 * @param mixed $uploadedNames posted value of the field
364 * @param mixed $postedFileNames $_FILES[field]['name']
365 *
366 * @return int
367 */
368 public static function countSubmittedFiles($uploadedNames, $postedFileNames)
369 {
370 $count = 0;
371 foreach ([$uploadedNames, $postedFileNames] as $source) {
372 $names = is_array($source) ? $source : (is_scalar($source) ? explode(',', (string) $source) : []);
373 foreach ($names as $name) {
374 if (is_scalar($name) && '' !== trim((string) $name)) {
375 $count++;
376 }
377 }
378 }
379
380 return $count;
381 }
382
383 /**
384 * The browser drops files past the limit, so more can only arrive from a crafted request.
385 *
386 * @param object|null $config field config
387 * @param mixed $uploadedNames posted value of the field
388 * @param mixed $postedFileNames $_FILES[field]['name']
389 *
390 * @return string error message, '' when within the limit
391 */
392 public static function getMaxFilesError($config, $uploadedNames, $postedFileNames)
393 {
394 $limit = self::getMaxFilesLimit($config);
395 if (null === $limit || self::countSubmittedFiles($uploadedNames, $postedFileNames) <= $limit) {
396 return '';
397 }
398
399 return sprintf(
400 /* translators: %d: maximum number of files */
401 _n('You can upload only %d file.', 'You can upload up to %d files.', $limit, 'bit-form'),
402 $limit
403 );
404 }
405
406 /**
407 * getMaxFilesError() for a field of a stored form.
408 *
409 * @param int|string $formId
410 * @param string $fieldKey
411 * @param mixed $uploadedNames posted value of the field
412 * @param mixed $postedFileNames $_FILES[field]['name']
413 *
414 * @return string
415 */
416 public static function getMaxFilesErrorForField($formId, $fieldKey, $uploadedNames, $postedFileNames)
417 {
418 $formContent = FormManager::getInstance($formId)->getFormContent();
419 $field = is_object($formContent) && isset($formContent->fields->{$fieldKey}) ? $formContent->fields->{$fieldKey} : null;
420 if (!is_object($field) || !isset($field->typ) || 'advanced-file-up' !== $field->typ) {
421 return '';
422 }
423
424 return self::getMaxFilesError(isset($field->config) ? $field->config : null, $uploadedNames, $postedFileNames);
425 }
426
427 /**
428 * Whether a MIME matches a `type/*` entry of the allow list, as FilePond accepts it. Markup and
429 * script types never match a wildcard: they must be listed explicitly.
430 *
431 * @param mixed $mime detected MIME, lowercase
432 * @param array $allowMimes allow-listed MIME entries, lowercase
433 *
434 * @return bool
435 */
436 public static function matchesWildcardMime($mime, $allowMimes)
437 {
438 $neverByWildcard = [
439 'text/html',
440 'application/xhtml+xml',
441 'text/javascript',
442 'application/javascript',
443 'application/x-javascript',
444 'application/ecmascript',
445 'text/ecmascript',
446 ];
447 if (!is_string($mime) || in_array($mime, $neverByWildcard, true)) {
448 return false;
449 }
450 $slash = strpos($mime, '/');
451 if (false === $slash || 0 === $slash) {
452 return false;
453 }
454
455 return in_array(substr($mime, 0, $slash) . '/*', (array) $allowMimes, true);
456 }
457
458 public function validation($field_key, $file_details, $form_id)
459 {
460 if (!function_exists('wp_check_filetype_and_ext')) {
461 require_once ABSPATH . 'wp-admin/includes/file.php';
462 }
463
464 $formManager = FormManager::getInstance($form_id);
465 $form_contents = $formManager->getFormContent();
466 $field_content_details = $form_contents->fields;
467 $fieldDetail = $field_content_details->{$field_key};
468 $fieldType = $fieldDetail->typ;
469 $maxSizeDetails = [];
470 $allowFileTypes = [];
471 $maxSize = null;
472 $minSize = null;
473 $fieldConfig = isset($fieldDetail->config) && is_object($fieldDetail->config) ? $fieldDetail->config : null;
474 if ('file-up' === $fieldType) {
475 $allowFileTypes = !empty($fieldDetail->config->allowedFileType) ? $fieldDetail->config->allowedFileType : [];
476 if (!empty($fieldDetail->config->allowMaxSize)) {
477 if (!empty($fieldDetail->config->maxSize)) {
478 $maxSizeDetails['maxSize'] = $fieldDetail->config->maxSize . $fieldDetail->config->sizeUnit;
479 }
480 if (!empty($fieldDetail->config->isItTotalMax)) {
481 $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxSize . $fieldDetail->config->sizeUnit;
482 }
483 }
484 if (!empty($allowFileTypes)) {
485 $allowFileTypes = explode(',', $allowFileTypes);
486 }
487 } elseif ('advanced-file-up' === $fieldType) {
488 $allowFileTypes = !empty($fieldDetail->config->allowFileTypeValidation) ? $fieldDetail->config->acceptedFileTypes : [];
489 if (!empty($fieldDetail->config->allowFileSizeValidation)) {
490 if (!empty($fieldDetail->config->maxFileSize)) {
491 $maxSizeDetails['maxSize'] = $fieldDetail->config->maxFileSize;
492 }
493 if (!empty($fieldDetail->config->maxTotalFileSize)) {
494 $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxTotalFileSize;
495 }
496 // A client transform re-encodes images, so the uploaded copy can legitimately come out
497 // smaller than the original the browser checked.
498 if (!empty($fieldDetail->config->minFileSize) && empty($fieldDetail->config->allowImageTransform)) {
499 // FilePond counts 1KB as 1000 bytes; a 1024 base would reject files the browser accepted.
500 $minSize = $this->getByteSizeByUnit($fieldDetail->config->minFileSize, 1000);
501 }
502 }
503 }
504 if (!empty($maxSizeDetails['maxSize'])) {
505 $maxSize = $this->getByteSizeByUnit($maxSizeDetails['maxSize']);
506 }
507 $maxTotalFileSize = null;
508 if (!empty($maxSizeDetails['maxTotalFileSize'])) {
509 $maxTotalFileSize = $this->getByteSizeByUnit($maxSizeDetails['maxTotalFileSize']);
510 }
511
512 if ($formManager->isRepeatedField($field_key)) {
513 foreach ($file_details['name'] as $rowIndex => $file) {
514 if (!empty($file)) {
515 $fileDetails = [
516 'name' => $file,
517 'type' => $file_details['type'][$rowIndex],
518 'tmp_name' => $file_details['tmp_name'][$rowIndex],
519 'error' => $file_details['error'][$rowIndex],
520 'size' => $file_details['size'][$rowIndex],
521 ];
522 $countState = $this->maxFilesState($fieldType, $fieldConfig, $file);
523 if (!empty($countState)) {
524 return $countState;
525 }
526 $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize);
527 if (!empty($validateState) && !empty($validateState['message'])) {
528 return $validateState;
529 }
530 }
531 }
532 } else {
533 $countState = $this->maxFilesState($fieldType, $fieldConfig, isset($file_details['name']) ? $file_details['name'] : null);
534 if (!empty($countState)) {
535 return $countState;
536 }
537 return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize);
538 }
539 return [];
540 }
541
542 /**
543 * @param string $fieldType
544 * @param object|null $fieldConfig
545 * @param mixed $postedFileNames $_FILES[field]['name'] (or one repeater row of it)
546 *
547 * @return array validation state, empty when within the limit
548 */
549 private function maxFilesState($fieldType, $fieldConfig, $postedFileNames)
550 {
551 if ('advanced-file-up' !== $fieldType) {
552 return [];
553 }
554 $message = self::getMaxFilesError($fieldConfig, null, $postedFileNames);
555
556 return '' === $message ? [] : ['message' => $message, 'error_type' => 'file_count_error'];
557 }
558
559 private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize, $minSize = null)
560 {
561 $errorMessage = [
562 'message' => '',
563 'error_type' => '',
564 ];
565 if (is_array($file_details['name'])) {
566 $totalSize = 0;
567 foreach ($file_details['name'] as $key => $file) {
568 if (!empty($file)) {
569 $fileInfo = [
570 'name' => $file,
571 'type' => $file_details['type'][$key],
572 'tmp_name' => $file_details['tmp_name'][$key],
573 'error' => $file_details['error'][$key],
574 'size' => $file_details['size'][$key],
575 ];
576 $totalSize += $fileInfo['size'];
577 $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize, $minSize);
578 if (!empty($validateState)) {
579 return $validateState;
580 }
581 }
582 }
583 if (isset($maxTotalFileSize) && !is_null($maxTotalFileSize) && $totalSize > $maxTotalFileSize) {
584 $errorMessage['message'] = __('Total File size is too large', 'bit-form');
585 $errorMessage['error_type'] = 'file_size_error';
586 return $errorMessage;
587 }
588 } else {
589 $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize, $minSize);
590 if (!empty($validateState)) {
591 return $validateState;
592 }
593 }
594
595 return $errorMessage;
596 }
597
598 private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null, $minSize = null)
599 {
600 // 0) Basic sanity & transport integrity
601 if (!is_array($file) || empty($file['tmp_name'])) {
602 // return ['message' => __('No file uploaded.', 'bit-form'), 'error_type' => 'file_missing'];
603 return null;
604 }
605 if (!isset($file['error']) || UPLOAD_ERR_OK !== (int)$file['error']) {
606 return ['message' => __('Upload failed', 'bit-form'), 'error_type' => 'file_upload_error'];
607 }
608 if (!is_uploaded_file($file['tmp_name'])) {
609 return ['message' => __('Untrusted upload source', 'bit-form'), 'error_type' => 'file_upload_error'];
610 }
611 if (!is_file($file['tmp_name']) || !is_readable($file['tmp_name'])) {
612 return ['message' => __('Temporary file not accessible', 'bit-form'), 'error_type' => 'file_upload_error'];
613 }
614
615 $fileName = sanitize_file_name((string)($file['name'] ?? ''));
616 if ('' === $fileName) {
617 return ['message' => __('Empty filename', 'bit-form'), 'error_type' => 'file_type_error'];
618 }
619
620 // 1) Enforce max size (header + actual)
621 $onDiskSize = @filesize($file['tmp_name']);
622 if (false === $onDiskSize) {
623 return ['message' => __('Cannot read file size', 'bit-form'), 'error_type' => 'file_upload_error'];
624 }
625 if (!empty($maxSize) && $onDiskSize > $maxSize) {
626 return ['message' => __('File size is too large', 'bit-form'), 'error_type' => 'file_size_error'];
627 }
628 if (!empty($minSize) && $onDiskSize < $minSize) {
629 return ['message' => __('File size is too small', 'bit-form'), 'error_type' => 'file_size_error'];
630 }
631
632 // 2) Determine ext + MIME using WP + finfo
633 $wpCheck = wp_check_filetype_and_ext($file['tmp_name'], $fileName); // ['ext'=>'jpg','type'=>'image/jpeg']
634 $fileExtension = strtolower((string)(empty($wpCheck['ext']) ? pathinfo($fileName, PATHINFO_EXTENSION) : $wpCheck['ext']));
635 $wpExt = strtolower((string)(empty($wpCheck['ext']) ? $fileExtension : $wpCheck['ext']));
636 $wpType = strtolower((string)($wpCheck['type'] ?? ''));
637 $fi = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false;
638 $detectedMime = $fi ? @finfo_file($fi, $file['tmp_name']) : false;
639 if ($fi) {
640 @finfo_close($fi);
641 }
642 if (!$detectedMime && function_exists('mime_content_type')) {
643 $detectedMime = @mime_content_type($file['tmp_name']);
644 }
645 if (!$detectedMime) {
646 $detectedMime = '' !== $wpType ? $wpType : 'application/octet-stream';
647 }
648 $detectedMime = strtolower(trim($detectedMime));
649
650 // Hard-block risky types regardless
651 // 3) Block obvious executable types regardless of allow list
652 $disallowedMimes = apply_filters('bitform_filter_upload_disallowed_mimes', [
653 'application/x-php',
654 'text/x-php',
655 'application/x-msdownload',
656 'application/x-msdos-program',
657 'application/x-sh',
658 'application/x-csh',
659 'text/x-shellscript',
660 'application/java-archive'
661 ]);
662 $denyExt = apply_filters('bitform_filter_upload_denied_extensions', ['php', 'phtml', 'phar', 'htaccess', 'html', 'js', 'exe', 'sh', 'bat', 'cmd']);
663 if (in_array($detectedMime, $disallowedMimes, true) || in_array($fileExtension, $denyExt, true)) {
664 return ['message' => __('This file type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
665 }
666
667 // 4) Normalize allowlist: support both extensions (.jpg or jpg) and MIME types
668 // --- ALLOWLIST NORMALIZATION (extensions + MIME) ---
669 $normalizedAllow = array_values(array_unique(array_map(static function ($t) {
670 return strtolower(trim((string)$t));
671 }, (array)$allowTypes)));
672
673 $allowExts = [];
674 $allowMimes = [];
675 foreach ($normalizedAllow as $t) {
676 if ('' === $t) {
677 continue;
678 }
679 if (false !== strpos($t, '/')) {
680 // looks like a MIME
681 $allowMimes[] = $t;
682 } else {
683 // extension: may include leading dot; normalize without dot
684 $allowExts[] = ltrim($t, '.');
685 }
686 }
687
688 $allowExts = array_values(array_unique($allowExts));
689 $allowMimes = array_values(array_unique($allowMimes));
690
691 $hasAllowlist = (!empty($allowExts) || !empty($allowMimes));
692 $extMatch = in_array($fileExtension, $allowExts, true);
693 $mimeMatch = in_array($detectedMime, $allowMimes, true) || self::matchesWildcardMime($detectedMime, $allowMimes);
694
695 // 5) Require BOTH a legit WP mapping AND a match to the allowlist
696 $wpOk = ('' !== $wpExt && '' !== $wpType);
697
698 if ($hasAllowlist) {
699 if (!($extMatch || $mimeMatch)) {
700 return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
701 }
702 } else {
703 if (!$wpOk) {
704 return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
705 }
706 }
707
708 // 5) Special SVG handling (by MIME, not just extension)
709 if ('svg' === $fileExtension || 'image/svg+xml' === $detectedMime || 'image/svg+xml' === $wpType) {
710 if ('image/svg+xml' !== $detectedMime) {
711 return ['message' => __('Invalid SVG', 'bit-form'), 'error_type' => 'file_type_error'];
712 }
713
714 $dirty = file_get_contents($file['tmp_name']);
715 $svg_sanitizer = new Sanitizer();
716 $clean = $svg_sanitizer->sanitize($dirty);
717 if (false === $clean) {
718 return ['message' => __('SVG file is not valid', 'bit-form'), 'error_type' => 'file_type_error'];
719 }
720 file_put_contents($file['tmp_name'], $clean, LOCK_EX);
721 // Re-check size post-sanitize
722 if (!empty($maxSize) && filesize($file['tmp_name']) > $maxSize) {
723 return ['message' => __('File size is too large after sanitation', 'bit-form'), 'error_type' => 'file_size_error'];
724 }
725 }
726
727 if ('pdf' === $fileExtension || 'application/pdf' === $detectedMime || 'application/pdf' === $wpType) {
728 $blockedList = [
729 '/\/JS\b/',
730 '/\/JavaScript\b/',
731 '/eval\(/i',
732 '/app\.alert/i',
733 '/console\.log\b/i',
734 '/document\.write\b/i',
735
736 '/\/GoToR\b/i',
737 '/\/Launch\b/i',
738
739 '/\/EmbeddedFile\b/i',
740 '/\/EmbeddedFiles\b/i',
741 '/\/Filespec\b/i',
742 '/\/FileAttachment\b/i',
743
744 '/\/SubmitForm\b/i',
745 '/\/ResetForm\b/i',
746 '/\/ImportData\b/i',
747
748 '/\/RichMedia\b/i',
749 ];
750
751 $pdfContent = file_get_contents($file['tmp_name']);
752 foreach ($blockedList as $blockedKeyWrd) {
753 if (preg_match($blockedKeyWrd, $pdfContent)) {
754 return ['message' => __('Invalid file', 'bit-form'), 'error_type' => 'file_type_error'];
755 }
756 }
757 }
758
759 // --- Allow developer to make a final decision override (optional) ---
760 $final = apply_filters('bitform_filter_upload_allow_file', true, [
761 'file_name' => $fileName,
762 'extension' => $fileExtension,
763 'detected_mime' => $detectedMime,
764 'wp_ext' => $wpExt,
765 'wp_type' => $wpType,
766 'allow_exts' => $allowExts,
767 'allow_mimes' => $allowMimes,
768 'has_allowlist' => $hasAllowlist,
769 ]);
770 if (true !== $final) {
771 // If a dev returns a WP_Error, you could extract message/type; here we just block.
772 return ['message' => __('File not allowed by policy', 'bit-form'), 'error_type' => 'file_policy_block'];
773 }
774 return null; // success
775 }
776
777 public static function deleteIsFileExists($path)
778 {
779 if (file_exists($path)) {
780 wp_delete_file($path);
781 }
782 }
783
784 public static function getEntriesFileUploadDir($form_id, $entry_id)
785 {
786 $uploadDir = rtrim(BITFORMS_UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR;
787 $encrypted_directoryId = Helpers::getEncryptedEntryId($entry_id);
788 $encryptedDirectory = $uploadDir . $encrypted_directoryId;
789 if (is_dir($encryptedDirectory)) {
790 return $encryptedDirectory;
791 }
792 $oldEntriesFileUploadDir = Helpers::getOldEntriesFileUploadDir($uploadDir, $entry_id);
793 if (!empty($oldEntriesFileUploadDir) && is_dir($oldEntriesFileUploadDir)) {
794 return $oldEntriesFileUploadDir;
795 }
796 return $encryptedDirectory;
797 }
798
799 private static function replaceDocumentRoot($path)
800 {
801 $uploadDir = wp_upload_dir();
802 $basedir = wp_normalize_path(rtrim($uploadDir['basedir'], '/\\'));
803 $baseurl = rtrim($uploadDir['baseurl'], '/');
804 $normalPath = wp_normalize_path($path);
805
806 if (0 === strpos($normalPath, $basedir . '/') || $normalPath === $basedir) {
807 return $baseurl . substr($normalPath, strlen($basedir));
808 }
809
810 // Fallback: path outside uploads dir — strip ABSPATH and prepend home URL.
811 return home_url(ltrim(str_replace(wp_normalize_path(ABSPATH), '', $normalPath), '/'));
812 }
813
814 public static function getEntriesFileUploadURL($form_id, $entry_id)
815 {
816 $documentRoot = self::getEntriesFileUploadDir($form_id, $entry_id);
817 $url = self::replaceDocumentRoot($documentRoot);
818 return $url;
819 }
820
821 public static function createIndexFile($directory)
822 {
823 if (wp_mkdir_p($directory)) {
824 $indexFilePath = rtrim($directory, '/') . '/index.php';
825 if (!file_exists($indexFilePath)) {
826 try {
827 if (false === self::writeFile($indexFilePath, "<?php\n// No direct access allowed.")) {
828 throw new \Exception("Failed to create index.php in $directory");
829 }
830 } catch (\Exception $e) {
831 Log::debug_log('File creation Failed:' . $e->getMessage()); // Log the error for debugging
832 }
833 }
834 }
835 return false;
836 }
837
838 /**
839 * Initialise and return the WP_Filesystem abstraction layer.
840 *
841 * @return WP_Filesystem_Base|false
842 */
843 private static function initWpFilesystem()
844 {
845 global $wp_filesystem;
846 if (empty($wp_filesystem)) {
847 require_once ABSPATH . 'wp-admin/includes/file.php';
848 WP_Filesystem();
849 }
850 return $wp_filesystem;
851 }
852
853 /**
854 * Write (overwrite) content to a file using WP_Filesystem.
855 *
856 * @param string $filePath Absolute path to the file.
857 * @param string $content Content to write.
858 * @return bool True on success, false on failure.
859 */
860 public static function writeFile($filePath, $content)
861 {
862 $fs = self::initWpFilesystem();
863 if ($fs) {
864 return $fs->put_contents($filePath, $content, FS_CHMOD_FILE);
865 }
866 // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable.
867 return false !== file_put_contents($filePath, $content);
868 }
869
870 /**
871 * Append content to a file using WP_Filesystem.
872 * WP_Filesystem has no native append; we read + concatenate + write.
873 *
874 * @param string $filePath Absolute path to the file.
875 * @param string $content Content to append.
876 * @return bool True on success, false on failure.
877 */
878 public static function appendFile($filePath, $content)
879 {
880 $fs = self::initWpFilesystem();
881 if ($fs) {
882 $existing = $fs->exists($filePath) ? (string) $fs->get_contents($filePath) : '';
883 return $fs->put_contents($filePath, $existing . $content, FS_CHMOD_FILE);
884 }
885 // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable.
886 return false !== file_put_contents($filePath, $content, FILE_APPEND | LOCK_EX);
887 }
888
889 /**
890 * Read and return the full content of a file using WP_Filesystem.
891 *
892 * @param string $filePath Absolute path to the file.
893 * @return string File contents, or empty string if unreadable.
894 */
895 public static function readFile($filePath)
896 {
897 $fs = self::initWpFilesystem();
898 if ($fs && $fs->exists($filePath)) {
899 $content = $fs->get_contents($filePath);
900 return false !== $content ? $content : '';
901 }
902 // Fallback.
903 if (file_exists($filePath)) {
904 $content = file_get_contents($filePath);
905 return false !== $content ? $content : '';
906 }
907 return '';
908 }
909
910 public static function processRepeaterAttachment($repeaterKey, $fileKey, $fieldValue, $basePath, &$attachments)
911 {
912 if (!isset($fieldValue[$repeaterKey]) || !is_array($fieldValue[$repeaterKey])) {
913 return;
914 }
915
916 foreach ($fieldValue[$repeaterKey] as $repeaterRow) {
917 if (!isset($repeaterRow[$fileKey]) || empty($repeaterRow[$fileKey])) {
918 continue;
919 }
920
921 $fileValue = $repeaterRow[$fileKey];
922 self::addAttachmentFiles($fileValue, $basePath, $attachments);
923 }
924 }
925
926 public static function processRegularAttachment($fileKey, $fieldValue, $basePath, &$attachments)
927 {
928 if (!isset($fieldValue[$fileKey]) || empty($fieldValue[$fileKey])) {
929 return;
930 }
931
932 $fileValue = $fieldValue[$fileKey];
933 self::addAttachmentFiles($fileValue, $basePath, $attachments);
934 }
935
936 private static function addAttachmentFiles($fileValue, $basePath, &$attachments)
937 {
938 $baseResolved = realpath($basePath);
939 if (false === $baseResolved) {
940 Log::debug_log([
941 'message' => 'FileHandler::addAttachmentFiles base path invalid',
942 'basePath' => $basePath,
943 ]);
944 return;
945 }
946
947 $addFile = static function ($candidate) use ($baseResolved, &$attachments) {
948 $safeFile = is_string($candidate) ? trim($candidate) : '';
949 if ('' === $safeFile) {
950 return;
951 }
952 $path = $baseResolved . DIRECTORY_SEPARATOR . $safeFile;
953 $resolved = realpath($path);
954 if (false === $resolved || 0 !== strpos($resolved, $baseResolved . DIRECTORY_SEPARATOR)) {
955 Log::debug_log([
956 'message' => 'FileHandler::addAttachmentFiles blocked path traversal',
957 'file' => $candidate,
958 'resolved' => $resolved,
959 'baseResolved' => $baseResolved,
960 ]);
961 return;
962 }
963 if (is_readable($resolved)) {
964 $attachments[] = $resolved;
965 }
966 };
967
968 if (is_array($fileValue)) {
969 foreach ($fileValue as $singleFile) {
970 $addFile($singleFile);
971 }
972 return;
973 }
974
975 $addFile($fileValue);
976 }
977
978 /**
979 * Return file type by checking with mime type
980 * @param string $mime
981 * @return string
982 */
983 public static function getFileTypeByMime(string $mime)
984 {
985 $mime = strtolower($mime);
986
987 if (preg_match('/^image\//', $mime)) {
988 return 'image';
989 }
990
991 $compressed = [
992 'application/zip',
993 'application/x-rar-compressed',
994 'application/x-7z-compressed',
995 'application/gzip',
996 'application/x-tar',
997 'application/x-gtar',
998 'application/x-bzip2',
999 'application/x-archive',
1000 'application/vnd.debian.binary-package',
1001 ];
1002 if (in_array($mime, $compressed, true)) {
1003 return 'compressed';
1004 }
1005
1006 $presentation = [
1007 'application/vnd.ms-powerpoint',
1008 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
1009 'application/vnd.oasis.opendocument.presentation',
1010 'application/vnd.apple.keynote',
1011 ];
1012 if (in_array($mime, $presentation, true)) {
1013 return 'presentation';
1014 }
1015
1016 $document = [
1017 'application/pdf',
1018 'application/msword',
1019 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1020 'application/rtf',
1021 'text/plain',
1022 'application/vnd.oasis.opendocument.text',
1023 'application/x-tex',
1024 'text/rtf',
1025 ];
1026 if (in_array($mime, $document, true)) {
1027 return 'document';
1028 }
1029
1030 $data = [
1031 'text/csv',
1032 'application/xml',
1033 'text/xml',
1034 'application/sql',
1035 'application/vnd.ms-excel',
1036 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1037 'application/x-sqlite3',
1038 'application/octet-stream', // generic binary (could be db files)
1039 ];
1040 if (in_array($mime, $data, true)) {
1041 return 'data';
1042 }
1043
1044 if (preg_match('/^audio\//', $mime)) {
1045 return 'audio';
1046 }
1047
1048 if (preg_match('/^video\//', $mime)) {
1049 return 'video';
1050 }
1051
1052 return 'other';
1053 }
1054
1055 /**
1056 * Return file type by checking with extension
1057 * @param string $extension
1058 * @return string
1059 */
1060 public static function getFileTypeByExtension($extension)
1061 {
1062 switch (strtolower($extension)) {
1063 case 'xbm':
1064 case 'tif':
1065 case 'pjp':
1066 case 'pjpeg':
1067 case 'svgz':
1068 case 'jpg':
1069 case 'jpeg':
1070 case 'ico':
1071 case 'tiff':
1072 case 'gif':
1073 case 'svg':
1074 case 'bmp':
1075 case 'png':
1076 case 'jfif':
1077 case 'webp':
1078 return 'image';
1079
1080 case '7z':
1081 case 'arj':
1082 case 'deb':
1083 case 'pkg':
1084 case 'rar':
1085 case 'rpm':
1086 case 'gz':
1087 case 'z':
1088 case 'zip':
1089 return 'compressed';
1090
1091 case 'key':
1092 case 'odp':
1093 case 'pps':
1094 case 'ppt':
1095 case 'pptx':
1096 return 'presentation';
1097
1098 case '_rf_':
1099 case 'doc':
1100 case 'docx':
1101 case 'odt':
1102 case 'pdf':
1103 case 'rtf':
1104 case 'tex':
1105 case 'txt':
1106 case 'wks':
1107 case 'wps':
1108 case 'wpd':
1109 return 'document';
1110
1111 case 'csv':
1112 case 'dat':
1113 case 'db':
1114 case 'dbf':
1115 case 'log':
1116 case 'mdb':
1117 case 'sav':
1118 case 'sql':
1119 case 'tar':
1120 case 'sqlite':
1121 case 'xml':
1122 return 'data';
1123
1124 case 'opus':
1125 case 'flac':
1126 case 'webm':
1127 case 'weba':
1128 case 'wav':
1129 case 'ogg':
1130 case 'm4a':
1131 case 'mp3':
1132 case 'oga':
1133 case 'mid':
1134 case 'amr':
1135 case 'aiff':
1136 case 'wma':
1137 case 'au':
1138 case 'acc':
1139 case 'wpl':
1140 return 'audio';
1141
1142 case 'ogm':
1143 case 'wmv':
1144 case 'mpg':
1145 case 'ogv':
1146 case 'mov':
1147 case 'asx':
1148 case 'mpeg':
1149 case 'mp4':
1150 case 'm4v':
1151 case 'avi':
1152 case '3gp':
1153 case 'flv':
1154 case 'mkv':
1155 case 'swf':
1156 return 'video';
1157 default:
1158 return 'other';
1159 }
1160 }
1161 }
1162