PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.1.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.1.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.1.2, at includes/Core/Util/FileHandler.php

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