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

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