exists($dir)) { $fs->delete($dir, true); } } public function cpyr($source, $destination) { if (is_dir($source)) { wp_mkdir_p($destination); // chmod($destination, 0744); $objects = scandir($source); foreach ($objects as $object) { if ('.' !== $object && '..' !== $object) { if (is_dir($source . DIRECTORY_SEPARATOR . $object) && !is_link($source . DIRECTORY_SEPARATOR . $object)) { cpyr($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object); } elseif (is_file($source . DIRECTORY_SEPARATOR . $object)) { copy($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object); // chmod($destination. DIRECTORY_SEPARATOR .$object, 0644); } else { symlink($source . DIRECTORY_SEPARATOR . $object, $destination . DIRECTORY_SEPARATOR . $object); } } } } else { copy($source, $destination); } } public function moveUploadedFiles($file_details, $form_id, $entry_id) { require_once ABSPATH . 'wp-admin/includes/file.php'; $file_upoalded = []; $_upload_dir = self::getEntriesFileUploadDir($form_id, $entry_id); $_upload_url = self::getEntriesFileUploadURL($form_id, $entry_id); $this::createIndexFile($_upload_dir); $upload_dir_filter = function ($uploads) use ($_upload_dir, $_upload_url) { $uploads['path'] = $_upload_dir; $uploads['url'] = $_upload_url; $uploads['subdir'] = ''; $uploads['basedir'] = dirname($_upload_dir); $uploads['baseurl'] = dirname($_upload_url); return $uploads; }; if (is_array($file_details['name'])) { foreach ($file_details['name'] as $key => $value) { if (!empty($value)) { $fileNameCount = 1; $file_upoalded[$key] = sanitize_file_name($value); while (file_exists($_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[$key])) { $fileNameWithSeparator = BITFORMS_BF_SEPARATOR . $fileNameCount; $file_upoalded[$key] = sanitize_file_name(preg_replace('/(.[a-z A-Z 0-9]+)$/', "{$fileNameWithSeparator}$1", $value)); $fileNameCount = $fileNameCount + 1; if (11 === $fileNameCount) { break; } } $file = [ 'name' => $file_upoalded[$key], 'type' => $file_details['type'][$key] ?? '', 'tmp_name' => $file_details['tmp_name'][$key], 'error' => $file_details['error'][$key] ?? 0, 'size' => $file_details['size'][$key] ?? 0, ]; add_filter('upload_dir', $upload_dir_filter); $upload_result = wp_handle_upload($file, ['test_form' => false]); remove_filter('upload_dir', $upload_dir_filter); if (isset($upload_result['error'])) { unset($file_upoalded[$key]); } else { $file_upoalded[$key] = basename($upload_result['file']); } } } } else { if (!empty($file_details['name'])) { $fileNameCount = 1; $file_upoalded[0] = sanitize_file_name($file_details['name']); while (file_exists($_upload_dir . DIRECTORY_SEPARATOR . $file_upoalded[0])) { $fileNameWithSeparator = BITFORMS_BF_SEPARATOR . $fileNameCount; $file_upoalded[0] = sanitize_file_name(preg_replace('/(.[a-z A-Z 0-9]+)$/', "{$fileNameWithSeparator}$1", $file_details['name'])); $fileNameCount = $fileNameCount + 1; if (11 === $fileNameCount) { break; } } $file = [ 'name' => $file_upoalded[0], 'type' => $file_details['type'] ?? '', 'tmp_name' => $file_details['tmp_name'], 'error' => $file_details['error'] ?? 0, 'size' => $file_details['size'] ?? 0, ]; add_filter('upload_dir', $upload_dir_filter); $upload_result = wp_handle_upload($file, ['test_form' => false]); remove_filter('upload_dir', $upload_dir_filter); if (isset($upload_result['error'])) { unset($file_upoalded[0]); } else { $file_upoalded[0] = basename($upload_result['file']); } } } return $file_upoalded; } public static function isSafeFileName($name) { if (!is_string($name)) { return false; } $trimmed = trim($name); if ('' === $trimmed) { return false; } $baseName = basename($trimmed); if ('' === $baseName || $baseName !== $trimmed || 'index.php' === $baseName) { return false; } return sanitize_file_name($trimmed) === $trimmed; } public function deleteFiles($form_id, $entry_id, $files) { $_upload_dir = self::getEntriesFileUploadDir($form_id, $entry_id); $resolvedBitformsUploadDir = realpath(BITFORMS_UPLOAD_DIR); $resolvedUploadDir = realpath($_upload_dir); if (false === $resolvedBitformsUploadDir || false === $resolvedUploadDir) { return; } $bitformsUploadDirPrefix = trailingslashit(wp_normalize_path($resolvedBitformsUploadDir)); $uploadDirPrefix = trailingslashit(wp_normalize_path($resolvedUploadDir)); if (0 !== strpos($uploadDirPrefix, $bitformsUploadDirPrefix)) { return; } foreach ((array) $files as $name) { if (!self::isSafeFileName($name)) { continue; } $candidatePath = $resolvedUploadDir . DIRECTORY_SEPARATOR . $name; $resolvedPath = realpath($candidatePath); if (false === $resolvedPath || !is_file($resolvedPath)) { continue; } $normalizedPath = wp_normalize_path($resolvedPath); if (0 !== strpos($normalizedPath, $uploadDirPrefix)) { continue; } wp_delete_file($resolvedPath); } } public static function getFileUploadError($code) { $errors = [ 0 => __('Unknown upload error', 'bit-form'), 1 => __('The uploaded file exceeds the upload_max_filesize directive in php.ini.', 'bit-form'), 2 => __('The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form.', 'bit-form'), 3 => __('The uploaded file was only partially uploaded.', 'bit-form'), 4 => __('No file was uploaded.', 'bit-form'), 6 => __('Missing a temporary folder.', 'bit-form'), 7 => __('Failed to write file to disk.', 'bit-form'), 8 => __('A PHP extension stopped the file upload.', 'bit-form'), ]; return $errors[$code]; } public static function fileCopy($tmpdir, $destinationDir, $file) { $tmpBase = realpath($tmpdir); $destBase = realpath($destinationDir); if (false === $tmpBase || false === $destBase) { Log::debug_log([ 'message' => 'FileHandler::fileCopy base path invalid', 'tmpdir' => $tmpdir, 'destinationDir' => $destinationDir, ]); return; } $safeFile = is_string($file) ? trim($file) : ''; if ('' === $safeFile) { return; } $candidate = $tmpBase . DIRECTORY_SEPARATOR . $safeFile; $resolved = realpath($candidate); if (false === $resolved || 0 !== strpos($resolved, $tmpBase . DIRECTORY_SEPARATOR)) { Log::debug_log([ 'message' => 'FileHandler::fileCopy blocked path traversal', 'file' => $file, 'resolved' => $resolved, 'tmpBase' => $tmpBase, ]); return; } if (!is_readable($resolved)) { return; } $newFile = $destBase . DIRECTORY_SEPARATOR . basename($resolved); copy($resolved, $newFile); } public static function tempDirToUploadDir($submitted_data, $fields, $formId, $entryID) { $upload_dir = wp_upload_dir(); $tempDir = $upload_dir['basedir'] . '/bitforms/temp'; $destinationDir = self::getEntriesFileUploadDir($formId, $entryID) . DIRECTORY_SEPARATOR; self::createIndexFile($destinationDir); $consumedFiles = []; foreach ($submitted_data as $key => $data) { if (isset($fields[$key]) && 'advanced-file-up' === $fields[$key]['type']) { $fldData = $submitted_data[$key]; // A repeater row (or a pre-split value) hands this field over as an array; explode() // on an array is a TypeError on PHP 8, which would fatal mid-submission. $files = \is_array($fldData) ? $fldData : explode(',', \is_scalar($fldData) ? (string) $fldData : ''); foreach ($files as $file) { $safeFile = \is_scalar($file) ? trim((string) $file) : ''; if ('' === $safeFile) { continue; } self::fileCopy($tempDir, $destinationDir, $safeFile); $consumedFiles[] = $safeFile; } if (!empty($files)) { $submitted_data[$key] = $files; } } } self::cleanupTempUploads($tempDir, $consumedFiles); return $submitted_data; } /** * Clear the shared temp upload staging area after a submission has taken what it needs. * * @param string $tempDir shared staging directory * @param array $consumedFiles file names copied into the entry directory by this submission * * @return void */ private static function cleanupTempUploads($tempDir, array $consumedFiles) { $tempBase = realpath($tempDir); if (false === $tempBase) { return; } $boundary = $tempBase . DIRECTORY_SEPARATOR; // Never remove the directory-hardening files the uploads dir relies on. $protected = ['index.php', 'index.html', '.htaccess']; foreach ($consumedFiles as $file) { $resolved = realpath($boundary . $file); if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) { continue; } if (\in_array(basename($resolved), $protected, true)) { continue; } wp_delete_file($resolved); } // Sweep abandoned uploads. Anything still here after the retention window belongs to a form // that was never submitted. Filterable so a site with very long multi-step forms can extend // it; 0 or less disables the sweep entirely. $retention = apply_filters('bitform_temp_upload_retention', DAY_IN_SECONDS); $retention = is_numeric($retention) ? (int) $retention : DAY_IN_SECONDS; if ($retention <= 0) { return; } $cutoff = time() - $retention; $tmpFiles = glob($boundary . '*'); foreach ((array) $tmpFiles as $tmpFile) { $resolved = realpath($tmpFile); if (false === $resolved || 0 !== strpos($resolved, $boundary) || !is_file($resolved)) { continue; } if (\in_array(basename($resolved), $protected, true)) { continue; } $modified = @filemtime($resolved); if (false !== $modified && $modified < $cutoff) { wp_delete_file($resolved); } } } private function getByteSizeByUnit($sizeString) { // split 2MB into 2 and MB $size = preg_replace('/[^0-9\.]/', '', $sizeString); $unit = preg_replace('/[^a-zA-Z]/', '', $sizeString); $unit = strtolower($unit); if ('kb' === $unit) { return $size * 1024; } elseif ('mb' === $unit) { return $size * 1024 * 1024; } elseif ('gb' === $unit) { return $size * 1024 * 1024 * 1024; } else { return $size; } } public function validation($field_key, $file_details, $form_id) { if (!function_exists('wp_check_filetype_and_ext')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $formManager = FormManager::getInstance($form_id); $form_contents = $formManager->getFormContent(); $field_content_details = $form_contents->fields; $fieldDetail = $field_content_details->{$field_key}; $fieldType = $fieldDetail->typ; $maxSizeDetails = []; $allowFileTypes = []; $maxSize = null; if ('file-up' === $fieldType) { $allowFileTypes = !empty($fieldDetail->config->allowedFileType) ? $fieldDetail->config->allowedFileType : []; if (!empty($fieldDetail->config->allowMaxSize)) { if (!empty($fieldDetail->config->maxSize)) { $maxSizeDetails['maxSize'] = $fieldDetail->config->maxSize . $fieldDetail->config->sizeUnit; } if (!empty($fieldDetail->config->isItTotalMax)) { $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxSize . $fieldDetail->config->sizeUnit; } } if (!empty($allowFileTypes)) { $allowFileTypes = explode(',', $allowFileTypes); } } elseif ('advanced-file-up' === $fieldType) { $allowFileTypes = !empty($fieldDetail->config->allowFileTypeValidation) ? $fieldDetail->config->acceptedFileTypes : []; if (!empty($fieldDetail->config->allowFileSizeValidation)) { if (!empty($fieldDetail->config->maxFileSize)) { $maxSizeDetails['maxSize'] = $fieldDetail->config->maxFileSize; } if (!empty($fieldDetail->config->maxTotalFileSize)) { $maxSizeDetails['maxTotalFileSize'] = $fieldDetail->config->maxTotalFileSize; } } } if (!empty($maxSizeDetails['maxSize'])) { $maxSize = $this->getByteSizeByUnit($maxSizeDetails['maxSize']); } $maxTotalFileSize = null; if (!empty($maxSizeDetails['maxTotalFileSize'])) { $maxTotalFileSize = $this->getByteSizeByUnit($maxSizeDetails['maxTotalFileSize']); } if ($formManager->isRepeatedField($field_key)) { foreach ($file_details['name'] as $rowIndex => $file) { if (!empty($file)) { $fileDetails = [ 'name' => $file, 'type' => $file_details['type'][$rowIndex], 'tmp_name' => $file_details['tmp_name'][$rowIndex], 'error' => $file_details['error'][$rowIndex], 'size' => $file_details['size'][$rowIndex], ]; $validateState = $this->validateFileInfo($fieldType, $fileDetails, $allowFileTypes, $maxSize, $maxTotalFileSize); if (!empty($validateState) && !empty($validateState['message'])) { return $validateState; } } } } else { return $this->validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize); } return []; } private function validateFileInfo($fieldType, $file_details, $allowFileTypes, $maxSize, $maxTotalFileSize) { $errorMessage = [ 'message' => '', 'error_type' => '', ]; if (is_array($file_details['name'])) { $totalSize = 0; foreach ($file_details['name'] as $key => $file) { if (!empty($file)) { $fileInfo = [ 'name' => $file, 'type' => $file_details['type'][$key], 'tmp_name' => $file_details['tmp_name'][$key], 'error' => $file_details['error'][$key], 'size' => $file_details['size'][$key], ]; $totalSize += $fileInfo['size']; $validateState = $this->validateSingleFile($fieldType, $fileInfo, $allowFileTypes, $maxSize); if (!empty($validateState)) { return $validateState; } } } if (isset($maxTotalFileSize) && !is_null($maxTotalFileSize) && $totalSize > $maxTotalFileSize) { $errorMessage['message'] = __('Total File size is too large', 'bit-form'); $errorMessage['error_type'] = 'file_size_error'; return $errorMessage; } } else { $validateState = $this->validateSingleFile($fieldType, $file_details, $allowFileTypes, $maxSize); if (!empty($validateState)) { return $validateState; } } return $errorMessage; } private function validateSingleFile($fieldType, &$file, $allowTypes, $maxSize = null) { // 0) Basic sanity & transport integrity if (!is_array($file) || empty($file['tmp_name'])) { // return ['message' => __('No file uploaded.', 'bit-form'), 'error_type' => 'file_missing']; return null; } if (!isset($file['error']) || UPLOAD_ERR_OK !== (int)$file['error']) { return ['message' => __('Upload failed', 'bit-form'), 'error_type' => 'file_upload_error']; } if (!is_uploaded_file($file['tmp_name'])) { return ['message' => __('Untrusted upload source', 'bit-form'), 'error_type' => 'file_upload_error']; } if (!is_file($file['tmp_name']) || !is_readable($file['tmp_name'])) { return ['message' => __('Temporary file not accessible', 'bit-form'), 'error_type' => 'file_upload_error']; } $fileName = sanitize_file_name((string)($file['name'] ?? '')); if ('' === $fileName) { return ['message' => __('Empty filename', 'bit-form'), 'error_type' => 'file_type_error']; } // 1) Enforce max size (header + actual) $onDiskSize = @filesize($file['tmp_name']); if (false === $onDiskSize) { return ['message' => __('Cannot read file size', 'bit-form'), 'error_type' => 'file_upload_error']; } if (!empty($maxSize) && $onDiskSize > $maxSize) { return ['message' => __('File size is too large', 'bit-form'), 'error_type' => 'file_size_error']; } // 2) Determine ext + MIME using WP + finfo $wpCheck = wp_check_filetype_and_ext($file['tmp_name'], $fileName); // ['ext'=>'jpg','type'=>'image/jpeg'] $fileExtension = strtolower((string)(empty($wpCheck['ext']) ? pathinfo($fileName, PATHINFO_EXTENSION) : $wpCheck['ext'])); $wpExt = strtolower((string)(empty($wpCheck['ext']) ? $fileExtension : $wpCheck['ext'])); $wpType = strtolower((string)($wpCheck['type'] ?? '')); $fi = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false; $detectedMime = $fi ? @finfo_file($fi, $file['tmp_name']) : false; if ($fi) { @finfo_close($fi); } if (!$detectedMime && function_exists('mime_content_type')) { $detectedMime = @mime_content_type($file['tmp_name']); } if (!$detectedMime) { $detectedMime = '' !== $wpType ? $wpType : 'application/octet-stream'; } $detectedMime = strtolower(trim($detectedMime)); // Hard-block risky types regardless // 3) Block obvious executable types regardless of allow list $disallowedMimes = apply_filters('bitform_filter_upload_disallowed_mimes', [ 'application/x-php', 'text/x-php', 'application/x-msdownload', 'application/x-msdos-program', 'application/x-sh', 'application/x-csh', 'text/x-shellscript', 'application/java-archive' ]); $denyExt = apply_filters('bitform_filter_upload_denied_extensions', ['php', 'phtml', 'phar', 'htaccess', 'html', 'js', 'exe', 'sh', 'bat', 'cmd']); if (in_array($detectedMime, $disallowedMimes, true) || in_array($fileExtension, $denyExt, true)) { return ['message' => __('This file type is not allowed', 'bit-form'), 'error_type' => 'file_type_error']; } // 4) Normalize allowlist: support both extensions (.jpg or jpg) and MIME types // --- ALLOWLIST NORMALIZATION (extensions + MIME) --- $normalizedAllow = array_values(array_unique(array_map(static function ($t) { return strtolower(trim((string)$t)); }, (array)$allowTypes))); $allowExts = []; $allowMimes = []; foreach ($normalizedAllow as $t) { if ('' === $t) { continue; } if (false !== strpos($t, '/')) { // looks like a MIME $allowMimes[] = $t; } else { // extension: may include leading dot; normalize without dot $allowExts[] = ltrim($t, '.'); } } $allowExts = array_values(array_unique($allowExts)); $allowMimes = array_values(array_unique($allowMimes)); $hasAllowlist = (!empty($allowExts) || !empty($allowMimes)); $extMatch = in_array($fileExtension, $allowExts, true); $mimeMatch = in_array($detectedMime, $allowMimes, true); // 5) Require BOTH a legit WP mapping AND a match to the allowlist $wpOk = ('' !== $wpExt && '' !== $wpType); if ($hasAllowlist) { if (!($extMatch || $mimeMatch)) { return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error']; } } else { if (!$wpOk) { return ['message' => __('File type is not allowed', 'bit-form'), 'error_type' => 'file_type_error']; } } // 5) Special SVG handling (by MIME, not just extension) if ('svg' === $fileExtension || 'image/svg+xml' === $detectedMime || 'image/svg+xml' === $wpType) { if ('image/svg+xml' !== $detectedMime) { return ['message' => __('Invalid SVG', 'bit-form'), 'error_type' => 'file_type_error']; } $dirty = file_get_contents($file['tmp_name']); $svg_sanitizer = new Sanitizer(); $clean = $svg_sanitizer->sanitize($dirty); if (false === $clean) { return ['message' => __('SVG file is not valid', 'bit-form'), 'error_type' => 'file_type_error']; } file_put_contents($file['tmp_name'], $clean, LOCK_EX); // Re-check size post-sanitize if (!empty($maxSize) && filesize($file['tmp_name']) > $maxSize) { return ['message' => __('File size is too large after sanitation', 'bit-form'), 'error_type' => 'file_size_error']; } } if ('pdf' === $fileExtension || 'application/pdf' === $detectedMime || 'application/pdf' === $wpType) { $blockedList = [ '/\/JS\b/', '/\/JavaScript\b/', '/eval\(/i', '/app\.alert/i', '/console\.log\b/i', '/document\.write\b/i', '/\/GoToR\b/i', '/\/Launch\b/i', '/\/EmbeddedFile\b/i', '/\/EmbeddedFiles\b/i', '/\/Filespec\b/i', '/\/FileAttachment\b/i', '/\/SubmitForm\b/i', '/\/ResetForm\b/i', '/\/ImportData\b/i', '/\/RichMedia\b/i', ]; $pdfContent = file_get_contents($file['tmp_name']); foreach ($blockedList as $blockedKeyWrd) { if (preg_match($blockedKeyWrd, $pdfContent)) { return ['message' => __('Invalid file', 'bit-form'), 'error_type' => 'file_type_error']; } } } // --- Allow developer to make a final decision override (optional) --- $final = apply_filters('bitform_filter_upload_allow_file', true, [ 'file_name' => $fileName, 'extension' => $fileExtension, 'detected_mime' => $detectedMime, 'wp_ext' => $wpExt, 'wp_type' => $wpType, 'allow_exts' => $allowExts, 'allow_mimes' => $allowMimes, 'has_allowlist' => $hasAllowlist, ]); if (true !== $final) { // If a dev returns a WP_Error, you could extract message/type; here we just block. return ['message' => __('File not allowed by policy', 'bit-form'), 'error_type' => 'file_policy_block']; } return null; // success } public static function deleteIsFileExists($path) { if (file_exists($path)) { wp_delete_file($path); } } public static function getEntriesFileUploadDir($form_id, $entry_id) { $uploadDir = rtrim(BITFORMS_UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $form_id . DIRECTORY_SEPARATOR; $encrypted_directoryId = Helpers::getEncryptedEntryId($entry_id); $encryptedDirectory = $uploadDir . $encrypted_directoryId; if (is_dir($encryptedDirectory)) { return $encryptedDirectory; } $oldEntriesFileUploadDir = Helpers::getOldEntriesFileUploadDir($uploadDir, $entry_id); if (!empty($oldEntriesFileUploadDir) && is_dir($oldEntriesFileUploadDir)) { return $oldEntriesFileUploadDir; } return $encryptedDirectory; } private static function replaceDocumentRoot($path) { $uploadDir = wp_upload_dir(); $basedir = wp_normalize_path(rtrim($uploadDir['basedir'], '/\\')); $baseurl = rtrim($uploadDir['baseurl'], '/'); $normalPath = wp_normalize_path($path); if (0 === strpos($normalPath, $basedir . '/') || $normalPath === $basedir) { return $baseurl . substr($normalPath, strlen($basedir)); } // Fallback: path outside uploads dir — strip ABSPATH and prepend home URL. return home_url(ltrim(str_replace(wp_normalize_path(ABSPATH), '', $normalPath), '/')); } public static function getEntriesFileUploadURL($form_id, $entry_id) { $documentRoot = self::getEntriesFileUploadDir($form_id, $entry_id); $url = self::replaceDocumentRoot($documentRoot); return $url; } public static function createIndexFile($directory) { if (wp_mkdir_p($directory)) { $indexFilePath = rtrim($directory, '/') . '/index.php'; if (!file_exists($indexFilePath)) { try { if (false === self::writeFile($indexFilePath, "getMessage()); // Log the error for debugging } } } return false; } /** * Initialise and return the WP_Filesystem abstraction layer. * * @return WP_Filesystem_Base|false */ private static function initWpFilesystem() { global $wp_filesystem; if (empty($wp_filesystem)) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } return $wp_filesystem; } /** * Write (overwrite) content to a file using WP_Filesystem. * * @param string $filePath Absolute path to the file. * @param string $content Content to write. * @return bool True on success, false on failure. */ public static function writeFile($filePath, $content) { $fs = self::initWpFilesystem(); if ($fs) { return $fs->put_contents($filePath, $content, FS_CHMOD_FILE); } // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable. return false !== file_put_contents($filePath, $content); } /** * Append content to a file using WP_Filesystem. * WP_Filesystem has no native append; we read + concatenate + write. * * @param string $filePath Absolute path to the file. * @param string $content Content to append. * @return bool True on success, false on failure. */ public static function appendFile($filePath, $content) { $fs = self::initWpFilesystem(); if ($fs) { $existing = $fs->exists($filePath) ? (string) $fs->get_contents($filePath) : ''; return $fs->put_contents($filePath, $existing . $content, FS_CHMOD_FILE); } // Fallback: file_put_contents is acceptable when WP_Filesystem is unavailable. return false !== file_put_contents($filePath, $content, FILE_APPEND | LOCK_EX); } /** * Read and return the full content of a file using WP_Filesystem. * * @param string $filePath Absolute path to the file. * @return string File contents, or empty string if unreadable. */ public static function readFile($filePath) { $fs = self::initWpFilesystem(); if ($fs && $fs->exists($filePath)) { $content = $fs->get_contents($filePath); return false !== $content ? $content : ''; } // Fallback. if (file_exists($filePath)) { $content = file_get_contents($filePath); return false !== $content ? $content : ''; } return ''; } public static function processRepeaterAttachment($repeaterKey, $fileKey, $fieldValue, $basePath, &$attachments) { if (!isset($fieldValue[$repeaterKey]) || !is_array($fieldValue[$repeaterKey])) { return; } foreach ($fieldValue[$repeaterKey] as $repeaterRow) { if (!isset($repeaterRow[$fileKey]) || empty($repeaterRow[$fileKey])) { continue; } $fileValue = $repeaterRow[$fileKey]; self::addAttachmentFiles($fileValue, $basePath, $attachments); } } public static function processRegularAttachment($fileKey, $fieldValue, $basePath, &$attachments) { if (!isset($fieldValue[$fileKey]) || empty($fieldValue[$fileKey])) { return; } $fileValue = $fieldValue[$fileKey]; self::addAttachmentFiles($fileValue, $basePath, $attachments); } private static function addAttachmentFiles($fileValue, $basePath, &$attachments) { $baseResolved = realpath($basePath); if (false === $baseResolved) { Log::debug_log([ 'message' => 'FileHandler::addAttachmentFiles base path invalid', 'basePath' => $basePath, ]); return; } $addFile = static function ($candidate) use ($baseResolved, &$attachments) { $safeFile = is_string($candidate) ? trim($candidate) : ''; if ('' === $safeFile) { return; } $path = $baseResolved . DIRECTORY_SEPARATOR . $safeFile; $resolved = realpath($path); if (false === $resolved || 0 !== strpos($resolved, $baseResolved . DIRECTORY_SEPARATOR)) { Log::debug_log([ 'message' => 'FileHandler::addAttachmentFiles blocked path traversal', 'file' => $candidate, 'resolved' => $resolved, 'baseResolved' => $baseResolved, ]); return; } if (is_readable($resolved)) { $attachments[] = $resolved; } }; if (is_array($fileValue)) { foreach ($fileValue as $singleFile) { $addFile($singleFile); } return; } $addFile($fileValue); } /** * Return file type by checking with mime type * @param string $mime * @return string */ public static function getFileTypeByMime(string $mime) { $mime = strtolower($mime); if (preg_match('/^image\//', $mime)) { return 'image'; } $compressed = [ 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', 'application/gzip', 'application/x-tar', 'application/x-gtar', 'application/x-bzip2', 'application/x-archive', 'application/vnd.debian.binary-package', ]; if (in_array($mime, $compressed, true)) { return 'compressed'; } $presentation = [ 'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.oasis.opendocument.presentation', 'application/vnd.apple.keynote', ]; if (in_array($mime, $presentation, true)) { return 'presentation'; } $document = [ 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/rtf', 'text/plain', 'application/vnd.oasis.opendocument.text', 'application/x-tex', 'text/rtf', ]; if (in_array($mime, $document, true)) { return 'document'; } $data = [ 'text/csv', 'application/xml', 'text/xml', 'application/sql', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'application/x-sqlite3', 'application/octet-stream', // generic binary (could be db files) ]; if (in_array($mime, $data, true)) { return 'data'; } if (preg_match('/^audio\//', $mime)) { return 'audio'; } if (preg_match('/^video\//', $mime)) { return 'video'; } return 'other'; } /** * Return file type by checking with extension * @param string $extension * @return string */ public static function getFileTypeByExtension($extension) { switch (strtolower($extension)) { case 'xbm': case 'tif': case 'pjp': case 'pjpeg': case 'svgz': case 'jpg': case 'jpeg': case 'ico': case 'tiff': case 'gif': case 'svg': case 'bmp': case 'png': case 'jfif': case 'webp': return 'image'; case '7z': case 'arj': case 'deb': case 'pkg': case 'rar': case 'rpm': case 'gz': case 'z': case 'zip': return 'compressed'; case 'key': case 'odp': case 'pps': case 'ppt': case 'pptx': return 'presentation'; case '_rf_': case 'doc': case 'docx': case 'odt': case 'pdf': case 'rtf': case 'tex': case 'txt': case 'wks': case 'wps': case 'wpd': return 'document'; case 'csv': case 'dat': case 'db': case 'dbf': case 'log': case 'mdb': case 'sav': case 'sql': case 'tar': case 'sqlite': case 'xml': return 'data'; case 'opus': case 'flac': case 'webm': case 'weba': case 'wav': case 'ogg': case 'm4a': case 'mp3': case 'oga': case 'mid': case 'amr': case 'aiff': case 'wma': case 'au': case 'acc': case 'wpl': return 'audio'; case 'ogm': case 'wmv': case 'mpg': case 'ogv': case 'mov': case 'asx': case 'mpeg': case 'mp4': case 'm4v': case 'avi': case '3gp': case 'flv': case 'mkv': case 'swf': return 'video'; default: return 'other'; } } }