PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.2.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.2.2
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 2.10.1 2.10.2 All 137 releases
bit-form / includes / Core / Util / FileHandler.php

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

1,006 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 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)
322 {
323 // split 2MB into 2 and MB
324 $size = preg_replace('/[^0-9\.]/', '', $sizeString);
325 $unit = preg_replace('/[^a-zA-Z]/', '', $sizeString);
326 $unit = strtolower($unit);
327 if ('kb' === $unit) {
328 return $size * 1024;
329 } elseif ('mb' === $unit) {
330 return $size * 1024 * 1024;
331 } elseif ('gb' === $unit) {
332 return $size * 1024 * 1024 * 1024;
333 } else {
334 return $size;
335 }
336 }
337
338 public function validation($field_key, $file_details, $form_id)
339 {
340 if (!function_exists('wp_check_filetype_and_ext')) {
341 require_once ABSPATH . 'wp-admin/includes/file.php';
342 }
343
344 $formManager = FormManager::getInstance($form_id);
345 $form_contents = $formManager->getFormContent();
346 $field_content_details = $form_contents->fields;
347 $fieldDetail = $field_content_details->{$field_key};
348 $fieldType = $fieldDetail->typ;
349 $maxSizeDetails = [];
350 $allowFileTypes = [];
351 $maxSize = null;
352 if ('file-up' === $fieldType) {
353 $allowFileTypes = !empty($fieldDetail->config->allowedFileType) ? $fieldDetail->config->allowedFileType : [];
354 if (!empty($fieldDetail->config->allowMaxSize)) {
355 if (!empty($fieldDetail->config->maxSize)) {
356 $maxSizeDetails['maxSize'] = $fieldDetail->config->maxSize . $fieldDetail->config->sizeUnit;
357 }
358 if (!empty($fieldDetail->config->isItTotalMax)) {
359 $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxSize . $fieldDetail->config->sizeUnit;
360 }
361 }
362 if (!empty($allowFileTypes)) {
363 $allowFileTypes = explode(',', $allowFileTypes);
364 }
365 } elseif ('advanced-file-up' === $fieldType) {
366 $allowFileTypes = !empty($fieldDetail->config->allowFileTypeValidation) ? $fieldDetail->config->acceptedFileTypes : [];
367 if (!empty($fieldDetail->config->allowFileSizeValidation)) {
368 if (!empty($fieldDetail->config->maxFileSize)) {
369 $maxSizeDetails['maxSize'] = $fieldDetail->config->maxFileSize;
370 }
371 if (!empty($fieldDetail->config->maxTotalFileSize)) {
372 $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxTotalFileSize;
373 }
374 }
375 }
376 if (!empty($maxSizeDetails['maxSize'])) {
377 $maxSize = $this->getByteSizeByUnit($maxSizeDetails['maxSize']);
378 }
379 $maxTotalFileSize = null;
380 if (!empty($maxSizeDetails['maxTotalFileSize'])) {
381 $maxTotalFileSize = $this->getByteSizeByUnit($maxSizeDetails['maxTotalFileSize']);
382 }
383
384 if ($formManager->isRepeatedField($field_key)) {
385 foreach ($file_details['name'] as $rowIndex => $file) {
386 if (!empty($file)) {
387 $fileDetails = [
388 'name' => $file,
389 'type' => $file_details['type'][$rowIndex],
390 'tmp_name' => $file_details['tmp_name'][$rowIndex],
391 'error' => $file_details['error'][$rowIndex],
392 'size' => $file_details['size'][$rowIndex],
393 ];
394 $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize);
395 if (!empty($validateState) && !empty($validateState['message'])) {
396 return $validateState;
397 }
398 }
399 }
400 } else {
401 return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize);
402 }
403 return [];
404 }
405
406 private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize)
407 {
408 $errorMessage = [
409 'message' => '',
410 'error_type' => '',
411 ];
412 if (is_array($file_details['name'])) {
413 $totalSize = 0;
414 foreach ($file_details['name'] as $key => $file) {
415 if (!empty($file)) {
416 $fileInfo = [
417 'name' => $file,
418 'type' => $file_details['type'][$key],
419 'tmp_name' => $file_details['tmp_name'][$key],
420 'error' => $file_details['error'][$key],
421 'size' => $file_details['size'][$key],
422 ];
423 $totalSize += $fileInfo['size'];
424 $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize);
425 if (!empty($validateState)) {
426 return $validateState;
427 }
428 }
429 }
430 if (isset($maxTotalFileSize) && !is_null($maxTotalFileSize) && $totalSize > $maxTotalFileSize) {
431 $errorMessage['message'] = __('Total File size is too large', 'bit-form');
432 $errorMessage['error_type'] = 'file_size_error';
433 return $errorMessage;
434 }
435 } else {
436 $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize);
437 if (!empty($validateState)) {
438 return $validateState;
439 }
440 }
441
442 return $errorMessage;
443 }
444
445 private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null)
446 {
447 // 0) Basic sanity & transport integrity
448 if (!is_array($file) || empty($file['tmp_name'])) {
449 // return ['message' => __('No file uploaded.', 'bit-form'), 'error_type' => 'file_missing'];
450 return null;
451 }
452 if (!isset($file['error']) || UPLOAD_ERR_OK !== (int)$file['error']) {
453 return ['message' => __('Upload failed', 'bit-form'), 'error_type' => 'file_upload_error'];
454 }
455 if (!is_uploaded_file($file['tmp_name'])) {
456 return ['message' => __('Untrusted upload source', 'bit-form'), 'error_type' => 'file_upload_error'];
457 }
458 if (!is_file($file['tmp_name']) || !is_readable($file['tmp_name'])) {
459 return ['message' => __('Temporary file not accessible', 'bit-form'), 'error_type' => 'file_upload_error'];
460 }
461
462 $fileName = sanitize_file_name((string)($file['name'] ?? ''));
463 if ('' === $fileName) {
464 return ['message' => __('Empty filename', 'bit-form'), 'error_type' => 'file_type_error'];
465 }
466
467 // 1) Enforce max size (header + actual)
468 $onDiskSize = @filesize($file['tmp_name']);
469 if (false === $onDiskSize) {
470 return ['message' => __('Cannot read file size', 'bit-form'), 'error_type' => 'file_upload_error'];
471 }
472 if (!empty($maxSize) && $onDiskSize > $maxSize) {
473 return ['message' => __('File size is too large', 'bit-form'), 'error_type' => 'file_size_error'];
474 }
475
476 // 2) Determine ext + MIME using WP + finfo
477 $wpCheck = wp_check_filetype_and_ext($file['tmp_name'], $fileName); // ['ext'=>'jpg','type'=>'image/jpeg']
478 $fileExtension = strtolower((string)(empty($wpCheck['ext']) ? pathinfo($fileName, PATHINFO_EXTENSION) : $wpCheck['ext']));
479 $wpExt = strtolower((string)(empty($wpCheck['ext']) ? $fileExtension : $wpCheck['ext']));
480 $wpType = strtolower((string)($wpCheck['type'] ?? ''));
481 $fi = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false;
482 $detectedMime = $fi ? @finfo_file($fi, $file['tmp_name']) : false;
483 if ($fi) {
484 @finfo_close($fi);
485 }
486 if (!$detectedMime && function_exists('mime_content_type')) {
487 $detectedMime = @mime_content_type($file['tmp_name']);
488 }
489 if (!$detectedMime) {
490 $detectedMime = '' !== $wpType ? $wpType : 'application/octet-stream';
491 }
492 $detectedMime = strtolower(trim($detectedMime));
493
494 // Hard-block risky types regardless
495 // 3) Block obvious executable types regardless of allow list
496 $disallowedMimes = apply_filters('bitform_filter_upload_disallowed_mimes', [
497 'application/x-php',
498 'text/x-php',
499 'application/x-msdownload',
500 'application/x-msdos-program',
501 'application/x-sh',
502 'application/x-csh',
503 'text/x-shellscript',
504 'application/java-archive'
505 ]);
506 $denyExt = apply_filters('bitform_filter_upload_denied_extensions', ['php', 'phtml', 'phar', 'htaccess', 'html', 'js', 'exe', 'sh', 'bat', 'cmd']);
507 if (in_array($detectedMime, $disallowedMimes, true) || in_array($fileExtension, $denyExt, true)) {
508 return ['message' => __('This file type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
509 }
510
511 // 4) Normalize allowlist: support both extensions (.jpg or jpg) and MIME types
512 // --- ALLOWLIST NORMALIZATION (extensions + MIME) ---
513 $normalizedAllow = array_values(array_unique(array_map(static function ($t) {
514 return strtolower(trim((string)$t));
515 }, (array)$allowTypes)));
516
517 $allowExts = [];
518 $allowMimes = [];
519 foreach ($normalizedAllow as $t) {
520 if ('' === $t) {
521 continue;
522 }
523 if (false !== strpos($t, '/')) {
524 // looks like a MIME
525 $allowMimes[] = $t;
526 } else {
527 // extension: may include leading dot; normalize without dot
528 $allowExts[] = ltrim($t, '.');
529 }
530 }
531
532 $allowExts = array_values(array_unique($allowExts));
533 $allowMimes = array_values(array_unique($allowMimes));
534
535 $hasAllowlist = (!empty($allowExts) || !empty($allowMimes));
536 $extMatch = in_array($fileExtension, $allowExts, true);
537 $mimeMatch = in_array($detectedMime, $allowMimes, true);
538
539 // 5) Require BOTH a legit WP mapping AND a match to the allowlist
540 $wpOk = ('' !== $wpExt && '' !== $wpType);
541
542 if ($hasAllowlist) {
543 if (!($extMatch || $mimeMatch)) {
544 return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
545 }
546 } else {
547 if (!$wpOk) {
548 return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error'];
549 }
550 }
551
552 // 5) Special SVG handling (by MIME, not just extension)
553 if ('svg' === $fileExtension || 'image/svg+xml' === $detectedMime || 'image/svg+xml' === $wpType) {
554 if ('image/svg+xml' !== $detectedMime) {
555 return ['message' => __('Invalid SVG', 'bit-form'), 'error_type' => 'file_type_error'];
556 }
557
558 $dirty = file_get_contents($file['tmp_name']);
559 $svg_sanitizer = new Sanitizer();
560 $clean = $svg_sanitizer->sanitize($dirty);
561 if (false === $clean) {
562 return ['message' => __('SVG file is not valid', 'bit-form'), 'error_type' => 'file_type_error'];
563 }
564 file_put_contents($file['tmp_name'], $clean, LOCK_EX);
565 // Re-check size post-sanitize
566 if (!empty($maxSize) && filesize($file['tmp_name']) > $maxSize) {
567 return ['message' => __('File size is too large after sanitation', 'bit-form'), 'error_type' => 'file_size_error'];
568 }
569 }
570
571 if ('pdf' === $fileExtension || 'application/pdf' === $detectedMime || 'application/pdf' === $wpType) {
572 $blockedList = [
573 '/\/JS\b/',
574 '/\/JavaScript\b/',
575 '/eval\(/i',
576 '/app\.alert/i',
577 '/console\.log\b/i',
578 '/document\.write\b/i',
579
580 '/\/GoToR\b/i',
581 '/\/Launch\b/i',
582
583 '/\/EmbeddedFile\b/i',
584 '/\/EmbeddedFiles\b/i',
585 '/\/Filespec\b/i',
586 '/\/FileAttachment\b/i',
587
588 '/\/SubmitForm\b/i',
589 '/\/ResetForm\b/i',
590 '/\/ImportData\b/i',
591
592 '/\/RichMedia\b/i',
593 ];
594
595 $pdfContent = file_get_contents($file['tmp_name']);
596 foreach ($blockedList as $blockedKeyWrd) {
597 if (preg_match($blockedKeyWrd, $pdfContent)) {
598 return ['message' => __('Invalid file', 'bit-form'), 'error_type' => 'file_type_error'];
599 }
600 }
601 }
602
603 // --- Allow developer to make a final decision override (optional) ---
604 $final = apply_filters('bitform_filter_upload_allow_file', true, [
605 'file_name' => $fileName,
606 'extension' => $fileExtension,
607 'detected_mime' => $detectedMime,
608 'wp_ext' => $wpExt,
609 'wp_type' => $wpType,
610 'allow_exts' => $allowExts,
611 'allow_mimes' => $allowMimes,
612 'has_allowlist' => $hasAllowlist,
613 ]);
614 if (true !== $final) {
615 // If a dev returns a WP_Error, you could extract message/type; here we just block.
616 return ['message' => __('File not allowed by policy', 'bit-form'), 'error_type' => 'file_policy_block'];
617 }
618 return null; // success
619 }
620
621 public static function deleteIsFileExists($path)
622 {
623 if (file_exists($path)) {
624 wp_delete_file($path);
625 }
626 }
627
628 public static function getEntriesFileUploadDir($form_id, $entry_id)
629 {
630 $uploadDir = rtrim(BITFORMS_UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR;
631 $encrypted_directoryId = Helpers::getEncryptedEntryId($entry_id);
632 $encryptedDirectory = $uploadDir . $encrypted_directoryId;
633 if (is_dir($encryptedDirectory)) {
634 return $encryptedDirectory;
635 }
636 $oldEntriesFileUploadDir = Helpers::getOldEntriesFileUploadDir($uploadDir, $entry_id);
637 if (!empty($oldEntriesFileUploadDir) && is_dir($oldEntriesFileUploadDir)) {
638 return $oldEntriesFileUploadDir;
639 }
640 return $encryptedDirectory;
641 }
642
643 private static function replaceDocumentRoot($path)
644 {
645 $uploadDir = wp_upload_dir();
646 $basedir = wp_normalize_path(rtrim($uploadDir['basedir'], '/\\'));
647 $baseurl = rtrim($uploadDir['baseurl'], '/');
648 $normalPath = wp_normalize_path($path);
649
650 if (0 === strpos($normalPath, $basedir . '/') || $normalPath === $basedir) {
651 return $baseurl . substr($normalPath, strlen($basedir));
652 }
653
654 // Fallback: path outside uploads dir — strip ABSPATH and prepend home URL.
655 return home_url(ltrim(str_replace(wp_normalize_path(ABSPATH), '', $normalPath), '/'));
656 }
657
658 public static function getEntriesFileUploadURL($form_id, $entry_id)
659 {
660 $documentRoot = self::getEntriesFileUploadDir($form_id, $entry_id);
661 $url = self::replaceDocumentRoot($documentRoot);
662 return $url;
663 }
664
665 public static function createIndexFile($directory)
666 {
667 if (wp_mkdir_p($directory)) {
668 $indexFilePath = rtrim($directory, '/') . '/index.php';
669 if (!file_exists($indexFilePath)) {
670 try {
671 if (false === self::writeFile($indexFilePath, "<?php\n// No direct access allowed.")) {
672 throw new \Exception("Failed to create index.php in $directory");
673 }
674 } catch (\Exception $e) {
675 Log::debug_log('File creation Failed:' . $e->getMessage()); // Log the error for debugging
676 }
677 }
678 }
679 return false;
680 }
681
682 /**
683 * Initialise and return the WP_Filesystem abstraction layer.
684 *
685 * @return WP_Filesystem_Base|false
686 */
687 private static function initWpFilesystem()
688 {
689 global $wp_filesystem;
690 if (empty($wp_filesystem)) {
691 require_once ABSPATH . 'wp-admin/includes/file.php';
692 WP_Filesystem();
693 }
694 return $wp_filesystem;
695 }
696
697 /**
698 * Write (overwrite) content to a file using WP_Filesystem.
699 *
700 * @param string $filePath Absolute path to the file.
701 * @param string $content Content to write.
702 * @return bool True on success, false on failure.
703 */
704 public static function writeFile($filePath, $content)
705 {
706 $fs = self::initWpFilesystem();
707 if ($fs) {
708 return $fs->put_contents($filePath, $content, FS_CHMOD_FILE);
709 }
710 // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable.
711 return false !== file_put_contents($filePath, $content);
712 }
713
714 /**
715 * Append content to a file using WP_Filesystem.
716 * WP_Filesystem has no native append; we read + concatenate + write.
717 *
718 * @param string $filePath Absolute path to the file.
719 * @param string $content Content to append.
720 * @return bool True on success, false on failure.
721 */
722 public static function appendFile($filePath, $content)
723 {
724 $fs = self::initWpFilesystem();
725 if ($fs) {
726 $existing = $fs->exists($filePath) ? (string) $fs->get_contents($filePath) : '';
727 return $fs->put_contents($filePath, $existing . $content, FS_CHMOD_FILE);
728 }
729 // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable.
730 return false !== file_put_contents($filePath, $content, FILE_APPEND | LOCK_EX);
731 }
732
733 /**
734 * Read and return the full content of a file using WP_Filesystem.
735 *
736 * @param string $filePath Absolute path to the file.
737 * @return string File contents, or empty string if unreadable.
738 */
739 public static function readFile($filePath)
740 {
741 $fs = self::initWpFilesystem();
742 if ($fs && $fs->exists($filePath)) {
743 $content = $fs->get_contents($filePath);
744 return false !== $content ? $content : '';
745 }
746 // Fallback.
747 if (file_exists($filePath)) {
748 $content = file_get_contents($filePath);
749 return false !== $content ? $content : '';
750 }
751 return '';
752 }
753
754 public static function processRepeaterAttachment($repeaterKey, $fileKey, $fieldValue, $basePath, &$attachments)
755 {
756 if (!isset($fieldValue[$repeaterKey]) || !is_array($fieldValue[$repeaterKey])) {
757 return;
758 }
759
760 foreach ($fieldValue[$repeaterKey] as $repeaterRow) {
761 if (!isset($repeaterRow[$fileKey]) || empty($repeaterRow[$fileKey])) {
762 continue;
763 }
764
765 $fileValue = $repeaterRow[$fileKey];
766 self::addAttachmentFiles($fileValue, $basePath, $attachments);
767 }
768 }
769
770 public static function processRegularAttachment($fileKey, $fieldValue, $basePath, &$attachments)
771 {
772 if (!isset($fieldValue[$fileKey]) || empty($fieldValue[$fileKey])) {
773 return;
774 }
775
776 $fileValue = $fieldValue[$fileKey];
777 self::addAttachmentFiles($fileValue, $basePath, $attachments);
778 }
779
780 private static function addAttachmentFiles($fileValue, $basePath, &$attachments)
781 {
782 $baseResolved = realpath($basePath);
783 if (false === $baseResolved) {
784 Log::debug_log([
785 'message' => 'FileHandler::addAttachmentFiles base path invalid',
786 'basePath' => $basePath,
787 ]);
788 return;
789 }
790
791 $addFile = static function ($candidate) use ($baseResolved, &$attachments) {
792 $safeFile = is_string($candidate) ? trim($candidate) : '';
793 if ('' === $safeFile) {
794 return;
795 }
796 $path = $baseResolved . DIRECTORY_SEPARATOR . $safeFile;
797 $resolved = realpath($path);
798 if (false === $resolved || 0 !== strpos($resolved, $baseResolved . DIRECTORY_SEPARATOR)) {
799 Log::debug_log([
800 'message' => 'FileHandler::addAttachmentFiles blocked path traversal',
801 'file' => $candidate,
802 'resolved' => $resolved,
803 'baseResolved' => $baseResolved,
804 ]);
805 return;
806 }
807 if (is_readable($resolved)) {
808 $attachments[] = $resolved;
809 }
810 };
811
812 if (is_array($fileValue)) {
813 foreach ($fileValue as $singleFile) {
814 $addFile($singleFile);
815 }
816 return;
817 }
818
819 $addFile($fileValue);
820 }
821
822 /**
823 * Return file type by checking with mime type
824 * @param string $mime
825 * @return string
826 */
827 public static function getFileTypeByMime(string $mime)
828 {
829 $mime = strtolower($mime);
830
831 if (preg_match('/^image\//', $mime)) {
832 return 'image';
833 }
834
835 $compressed = [
836 'application/zip',
837 'application/x-rar-compressed',
838 'application/x-7z-compressed',
839 'application/gzip',
840 'application/x-tar',
841 'application/x-gtar',
842 'application/x-bzip2',
843 'application/x-archive',
844 'application/vnd.debian.binary-package',
845 ];
846 if (in_array($mime, $compressed, true)) {
847 return 'compressed';
848 }
849
850 $presentation = [
851 'application/vnd.ms-powerpoint',
852 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
853 'application/vnd.oasis.opendocument.presentation',
854 'application/vnd.apple.keynote',
855 ];
856 if (in_array($mime, $presentation, true)) {
857 return 'presentation';
858 }
859
860 $document = [
861 'application/pdf',
862 'application/msword',
863 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
864 'application/rtf',
865 'text/plain',
866 'application/vnd.oasis.opendocument.text',
867 'application/x-tex',
868 'text/rtf',
869 ];
870 if (in_array($mime, $document, true)) {
871 return 'document';
872 }
873
874 $data = [
875 'text/csv',
876 'application/xml',
877 'text/xml',
878 'application/sql',
879 'application/vnd.ms-excel',
880 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
881 'application/x-sqlite3',
882 'application/octet-stream', // generic binary (could be db files)
883 ];
884 if (in_array($mime, $data, true)) {
885 return 'data';
886 }
887
888 if (preg_match('/^audio\//', $mime)) {
889 return 'audio';
890 }
891
892 if (preg_match('/^video\//', $mime)) {
893 return 'video';
894 }
895
896 return 'other';
897 }
898
899 /**
900 * Return file type by checking with extension
901 * @param string $extension
902 * @return string
903 */
904 public static function getFileTypeByExtension($extension)
905 {
906 switch (strtolower($extension)) {
907 case 'xbm':
908 case 'tif':
909 case 'pjp':
910 case 'pjpeg':
911 case 'svgz':
912 case 'jpg':
913 case 'jpeg':
914 case 'ico':
915 case 'tiff':
916 case 'gif':
917 case 'svg':
918 case 'bmp':
919 case 'png':
920 case 'jfif':
921 case 'webp':
922 return 'image';
923
924 case '7z':
925 case 'arj':
926 case 'deb':
927 case 'pkg':
928 case 'rar':
929 case 'rpm':
930 case 'gz':
931 case 'z':
932 case 'zip':
933 return 'compressed';
934
935 case 'key':
936 case 'odp':
937 case 'pps':
938 case 'ppt':
939 case 'pptx':
940 return 'presentation';
941
942 case '_rf_':
943 case 'doc':
944 case 'docx':
945 case 'odt':
946 case 'pdf':
947 case 'rtf':
948 case 'tex':
949 case 'txt':
950 case 'wks':
951 case 'wps':
952 case 'wpd':
953 return 'document';
954
955 case 'csv':
956 case 'dat':
957 case 'db':
958 case 'dbf':
959 case 'log':
960 case 'mdb':
961 case 'sav':
962 case 'sql':
963 case 'tar':
964 case 'sqlite':
965 case 'xml':
966 return 'data';
967
968 case 'opus':
969 case 'flac':
970 case 'webm':
971 case 'weba':
972 case 'wav':
973 case 'ogg':
974 case 'm4a':
975 case 'mp3':
976 case 'oga':
977 case 'mid':
978 case 'amr':
979 case 'aiff':
980 case 'wma':
981 case 'au':
982 case 'acc':
983 case 'wpl':
984 return 'audio';
985
986 case 'ogm':
987 case 'wmv':
988 case 'mpg':
989 case 'ogv':
990 case 'mov':
991 case 'asx':
992 case 'mpeg':
993 case 'mp4':
994 case 'm4v':
995 case 'avi':
996 case '3gp':
997 case 'flv':
998 case 'mkv':
999 case 'swf':
1000 return 'video';
1001 default:
1002 return 'other';
1003 }
1004 }
1005 }
1006