398 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			398 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?php
 | |
| 
 | |
| /**
 | |
|  * @author          Tassos Marinos <info@tassos.gr>
 | |
|  * @link            https://www.tassos.gr
 | |
|  * @copyright       Copyright © 2024 Tassos All Rights Reserved
 | |
|  * @license         GNU GPLv3 <http://www.gnu.org/licenses/gpl.html> 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
 | |
| 			<Files *.php>
 | |
| 				<IfModule !mod_authz_core.c>
 | |
| 					Deny from all
 | |
| 				</IfModule>
 | |
| 				<IfModule mod_authz_core.c>
 | |
| 					Require all denied
 | |
| 				</IfModule>
 | |
| 			</Files>
 | |
| 		';
 | |
| 
 | |
| 		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 = '<!DOCTYPE html><title></title>';
 | |
| 		
 | |
| 		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'));
 | |
| 	}
 | |
| } |