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'));
|
|
}
|
|
} |