<?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 Tassos\Framework;

defined( '_JEXEC' ) or die( 'Restricted access' );

use Tassos\Framework\Mimes;
use Tassos\Framework\Security\SafePath;
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|string	$random_prefix	True => random unique prefix; a string is used as a literal prefix; null/false => none
	 * @param	bool|string	$random_suffix	True => random unique suffix; a string is used as a literal suffix; null/false => none
	 *
	 * @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']) || !is_file($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']);

		// Documented as a bool toggle (true => random prefix). A string is also
		// accepted as a literal prefix (e.g. Convert Forms' 'cf_'). Bool true
		// must seed uniqid() with an empty string, otherwise (string) true
		// would leak a literal "1" into the name. null/false => no prefix.
		if ($random_prefix !== null && $random_prefix !== false)
		{
			$prefix = $random_prefix === true ? '' : $random_prefix;
			$filename = uniqid($prefix) . '_' . $filename;
		}

		// Mirror $random_prefix: true => random unique suffix (empty uniqid
		// seed); a string is used as a literal suffix; null/false => none.
		if ($random_suffix !== null && $random_suffix !== false)
		{
			$suffix    = $random_suffix === true ? '' : $random_suffix;
			$file_data = File::pathinfo($filename);
			$filename  = $file_data['filename'] . '_' . uniqid($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)
	{
		// Reject sources or destinations outside JPATH_ROOT or the configured tmp_path.
		if (!self::isWithinAllowedRoots($source_file) || !self::isWithinAllowedRoots($destination_file))
		{
			self::error(Text::sprintf('NR_CANNOT_MOVE_FILE', $destination_file));
		}

		$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)
	{
		// Reject sources or destinations outside JPATH_ROOT or the configured tmp_path.
		if (!self::isWithinAllowedRoots($source_file) || !self::isWithinAllowedRoots($destination_file))
		{
			self::error(Text::sprintf('NR_CANNOT_MOVE_FILE', $destination_file));
		}

		$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 = (string) Factory::getConfig()->get('tmp_path');

		if ($tmpdir !== '' && 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);

		// Check if the file exists
		if (!is_file($file_path))
		{
			self::error(Text::sprintf('NR_UPLOAD_ERROR_CANNOT_UPLOAD_FILE', $safeFilename));
		}
		
		// 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 access to any PHP executable file type.
			# Matches php, php3..php9, phps, pht, phtml, phtm, phar and inc case-insensitively.
			# The trailing (\.|$) also catches the token in any position (shell.php.jpg,
			# shell.php.), closing the AddHandler multi-extension bypass, not just names ending in it.
			<FilesMatch "(?i)\.(php|php[0-9]|phps|pht|phtml|phtm|phar|inc)(\.|$)">
				<IfModule !mod_authz_core.c>
					Deny from all
				</IfModule>
				<IfModule mod_authz_core.c>
					Require all denied
				</IfModule>
			</FilesMatch>
		';

		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)
	{
        $base = is_null($path) ? self::getTempFolder() : $path;

		// Confine the requested filename inside $base. absoluteWithin() rejects
		// null bytes, traversal sequences and absolute paths, then re-validates
		// the resolved path with realpath() on both sides and returns the validated path.
		try
		{
			$filename = SafePath::absoluteWithin($filename, $base);
		}
		catch (\Throwable $e)
		{
			self::error('Invalid filename');
		}

		if (!is_file($filename))
		{
			self::error('Invalid 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();
	}

	/**
	 * Delete a file.
	 *
	 * Accepts either an absolute path or a path relative to JPATH_ROOT. The
	 * resolved path must sit inside an allowed root — see isWithinAllowedRoots().
	 *
	 * @param   string  $filename	The absolute or JPATH_ROOT-relative file path
	 *
	 * @return  bool
	 */
	public static function delete($filename)
	{
		if (empty($filename) || !is_string($filename))
		{
			return;
		}

		if (\Tassos\Framework\Security\SafePath::isAbsolute($filename))
		{
			$file = Path::clean($filename);
		}
		else
		{
			// Defense-in-depth: reject traversal sequences on relative input
			try
			{
				\Tassos\Framework\Security\SafePath::sanitize($filename);
			}
			catch (\Throwable $e)
			{
				return false;
			}

			$file = Path::clean(implode(DIRECTORY_SEPARATOR, [JPATH_ROOT, $filename]));
		}

		if (!self::isWithinAllowedRoots($file))
		{
			return false;
		}

		if (!is_file($file))
		{
			return;
		}

		return JoomlaFile::delete($file);
	}

	/**
	 * True if $file sits inside JPATH_ROOT or the configured tmp_path.
	 *
	 * @param	string	$file	The file path to validate
	 *
	 * @return	bool
	 */
	private static function isWithinAllowedRoots($file)
	{
		$tmp_path = (string) Factory::getConfig()->get('tmp_path');

		foreach ([JPATH_ROOT, $tmp_path] as $root)
		{
			if ($root !== '' && SafePath::isWithinLexical($file, $root))
			{
				return true;
			}
		}

		return false;
	}

	/**
	 * Throw a sanitized exception
	 *
	 * @param	string	$error
	 *
	 * @return	void
	 */
	private static function error($error)
	{
		throw new \Exception(htmlspecialchars($error, ENT_QUOTES, 'UTF-8'));
	}
}