* @link https://www.tassos.gr * @copyright Copyright © 2024 Tassos All Rights Reserved * @license GNU GPLv3 or later */ namespace NRFramework; defined( '_JEXEC' ) or die( 'Restricted access' ); use NRFramework\Mimes; use Joomla\Filesystem\File as JoomlaFile; use Joomla\Filesystem\Path; use Joomla\CMS\Language\Text; use Joomla\CMS\Factory; class File { /** * Upload file * * @param array $file The request file as posted by form * @param string $upload_folder The upload folder where the file must be uploaded * @param string $allowed_file_types A comma separated list of allowed file types like: .jpg, .gif, .png * @param bool $allow_unsafe Allow the upload of unsafe files. See JFilterInput::isSafeFile() method. * @param bool $random_prefix If is set to true, the filename will get a random unique prefix * @param bool $random_suffix If is set to true, the filename will get a random unique suffix * * @return mixed String on success, Null on failure */ public static function upload($file, $upload_folder = null, $allowed_file_types = [], $allow_unsafe = false, $random_prefix = null, $random_suffix = false) { // Make sure we have a valid file array if (!isset($file['name']) || !isset($file['tmp_name'])) { self::error(Text::sprintf('NR_UPLOAD_ERROR_CANNOT_UPLOAD_FILE', $file['name'])); } // Check file type self::checkMimeOrDie($allowed_file_types, $file); /** * Try transiterating the file name using the native php function * * If the given filename is non-latin, then all characters will be removed from the filename via makeSafe and thus * we wont be able to upload the file. * * @see https://github.com/joomla/joomla-cms/pull/27974 */ if (!defined('t_isJ5') && function_exists('transliterator_transliterate') && function_exists('iconv')) { // Using iconv to ignore characters that can't be transliterated $file['name'] = iconv("UTF-8", "ASCII//TRANSLIT//IGNORE", transliterator_transliterate('Any-Latin; Latin-ASCII;', $file['name'])); } // Sanitize filename $filename = JoomlaFile::makeSafe($file['name']); if (!is_null($random_prefix)) { $filename = uniqid($random_prefix) . '_' . $filename; } if (is_bool($random_suffix) && $random_suffix === true) { $file_data = File::pathinfo($filename); $filename = $file_data['filename'] . '_' . uniqid($random_suffix) . '.' . $file_data['extension']; } $filename = str_replace(' ', '_', $filename); // Setup the full file name $upload_folder = is_null($upload_folder) ? self::getTempFolder() : $upload_folder; $destination_file = implode(DIRECTORY_SEPARATOR, [$upload_folder, $filename]); // If file exists, rename to copy_X self::uniquefy($destination_file); $destination_file = Path::clean($destination_file); if (!JoomlaFile::upload($file['tmp_name'], $destination_file, false, $allow_unsafe)) { self::error(Text::sprintf('NR_UPLOAD_ERROR_CANNOT_UPLOAD_FILE', $file['name'])); } return $destination_file; } /** * Moves a file from one directory to another. Destination directories will be created if they are not exist. * * @param string $source_file The source file path * @param string $destination_file The destination file path * @param bool $replace_existing Replace same files names, otherwise create a copy in the format copy_X * * @return mixed String on success */ public static function move($source_file, $destination_file, $replace_existing = false, $hash = false) { $destination_folder = dirname($destination_file); // Create destination folders recursively if (!self::createDirs($destination_folder)) { self::error(Text::sprintf('NR_CANNOT_CREATE_FOLDER', $destination_folder)); } // Don't replace files with the same name. Instead, append copy_x to this one. if (!$replace_existing) { self::uniquefy($destination_file, $hash); } // Move file to the destination folder if (!JoomlaFile::move($source_file, $destination_file)) { self::error(Text::sprintf('NR_CANNOT_MOVE_FILE', $destination_file)); } return Path::clean($destination_file); } /** * Copies a file from one directory to another. * * @param string $source_file The source file path * @param string $destination_file The destination file path * @param bool $replace_existing Replace same files names, otherwise create a copy in the format copy_X * @param bool $hash Whether to md5 hash the filename * * @return mixed String on success */ public static function copy($source_file, $destination_file, $replace_existing = false, $hash = false) { $destination_folder = dirname($destination_file); // Create destination folders recursively if (!self::createDirs($destination_folder)) { self::error(Text::sprintf('NR_CANNOT_CREATE_FOLDER', $destination_folder)); } // Don't replace files with the same name. Instead, append copy_x to this one. if (!$replace_existing) { self::uniquefy($destination_file, $hash); } // Copy file to the destination folder if (!JoomlaFile::copy($source_file, $destination_file)) { self::error(Text::sprintf('NR_CANNOT_MOVE_FILE', $destination_file)); } return Path::clean($destination_file); } /** * Reads (and checks) the temp Joomla folder * * @return string */ public static function getTempFolder() { $ds = DIRECTORY_SEPARATOR; $tmpdir = Factory::getConfig()->get('tmp_path'); if (realpath($tmpdir) == $ds . 'tmp') { $tmpdir = JPATH_SITE . $ds . 'tmp'; } elseif (!is_dir($tmpdir)) { $tmpdir = JPATH_SITE . $ds . 'tmp'; } return Path::clean(trim($tmpdir) . $ds); } /** * Checks if the path exists. If not creates the folders as well as subfolders. * * @param string $path The folder path * @param string $protect If set to true, each folder will be protected by disabling PHP engine and preventing folder browsing * * @return bool */ public static function createDirs($path, $protect = true) { if (!is_dir($path)) { mkdir($path, 0755, true); // New folder created. Let's protect it. if ($protect) { self::writeHtaccessFile($path); self::writeIndexHtmlFile($path); } } // Make sure the folder is writable return @is_writable($path); } /** * Checks whether a file type is in an allowed list * * @param mixed $allowed_types Array or a comma separated list of allowed file extensions or mime types. Eg: .jpg, .png, applicaton/pdf * @param string $file_object The uploaded file as appears in the $_FILES array * * @return bool */ public static function checkMimeOrDie($allowed_types, $file_object) { $file_path = $file_object['tmp_name']; $file_name = isset($file_object['name']) ? $file_object['name'] : basename($file_path); $safeFilename = strip_tags($file_name); $fileExtension = pathinfo($file_name, PATHINFO_EXTENSION); // First, validate file by its extension if (!Mimes::validateFileExtension($fileExtension, $allowed_types)) { self::error(Text::sprintf('NR_UPLOAD_INVALID_FILE_EXT', $safeFilename, $fileExtension, $allowed_types)); } // Do we have a mime type detected? if (!$mime_type = Mimes::detectFileType($file_path)) { self::error(Text::sprintf('NR_UPLOAD_NO_MIME_TYPE', $safeFilename)); } if (!Mimes::check($allowed_types, $mime_type)) { self::error(Text::sprintf('NR_UPLOAD_INVALID_FILE_TYPE', $safeFilename, $mime_type, $allowed_types)); } } /** * Add an .htaccess file to the folder in order to disable PHP engine entirely * * @param string $path The path where to write the file * * @return void */ public static function writeHtaccessFile($path) { $content = ' # Block direct PHP access Deny from all Require all denied '; JoomlaFile::write($path . '/.htaccess', $content); } /** * Creates an empty index.html file to prevent directory listing * * @param string $path The path where to write the file * * @return void */ public static function writeIndexHtmlFile($path) { $content = ''; JoomlaFile::write($path . '/index.html', $content); } /** * Generates a unique filename in case the give name already exists by appending copy_X suffix to filename. * * @param string $path The path to the file. * @param bool $hash MD5 hashes the file name. * * @return void */ public static function uniquefy(&$path, $hash = false) { $path_parts = self::pathinfo($path); $dir = $path_parts['dirname']; $ext = $path_parts['extension']; $actual_name = $path_parts['filename']; $original_name = $actual_name; // md5 hash the file name if ($hash) { $actual_name = md5($actual_name); // Initialize again the path due to md5 hash $path = $dir . '/' . $actual_name . '.' . $ext; } $i = 1; while(file_exists($dir . '/' . $actual_name . '.' . $ext)) { $actual_name = (string) $original_name . '_copy_' . $i; // md5 hash the file name if ($hash) { $actual_name = md5($actual_name); } $path = $dir . '/' . $actual_name . '.' . $ext; $i++; } } /** * Returns information about a file path with multi-byte support * * @param string $path The path to be parsed. * * @return array */ public static function pathinfo($path) { // Store temporary the currenty locale $currentLocale = setlocale(LC_ALL, 0); setlocale(LC_ALL, 'C.UTF-8'); $pathinfo = pathinfo($path); // Set back to previus value setlocale(LC_ALL, $currentLocale); return $pathinfo; } /** * Force download of the exported file * * @return void */ public static function download($filename, $path = null) { $path = is_null($path) ? self::getTempFolder() : $path; $filename = $path . '/' . $filename; if (!is_file($filename)) { self::error('Invalid filename ' . $filename); } error_reporting(0); // Send the appropriate headers to force the download in the browser header('Content-Description: File Transfer'); header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . basename($filename) . '"'); header('Expires: 0'); header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); header('Cache-Control: public', false); header('Pragma: public'); header('Content-Length: ' . @filesize($filename)); // Clear the output buffer and disable output buffering ob_clean(); flush(); // Read exported file to buffer readfile($filename); // Don't leave any clues on the server. Delete the file. JoomlaFile::delete($filename); jexit(); } /** * Throw a sanitized exception * * @param string $error * * @return void */ private static function error($error) { throw new \Exception(htmlspecialchars($error, ENT_QUOTES, 'UTF-8')); } }