Description
Hi @mevdschee, first of all I would like to thank you for this fantastic project: you had a beautiful idea and it helped me create very interesting works, and for this I wanted to give my contribution too. The only thing that in my opinion was missing (and that I needed in the past) was an integrated file manager. I then started to create a custom middleware. It works, it does its dirty job, but I want to discuss with you and the whole community to understand if it is taking the right direction before continuing with writing the code in vain. I am therefore sharing with all of you the code and a mini-documentation that I have written to help you understand what I have done so far. Please test it (not in production, although it works) and let me know what you think!
I commented the code as much as possible where necessary, read it! Let's collaborate!
namespace Controller\Custom {
use Exception;
use Imagick;
use ImagickException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use Tqdev\PhpCrudApi\Cache\Cache;
use Tqdev\PhpCrudApi\Column\ReflectionService;
use Tqdev\PhpCrudApi\Controller\Responder;
use Tqdev\PhpCrudApi\Database\GenericDB;
use Tqdev\PhpCrudApi\Middleware\Router\Router;
use Tqdev\PhpCrudApi\ResponseFactory;
class FileManagerController
{
/**
* @var Responder $responder The responder instance used to send responses.
*/
private $responder;
/**
* @var Cache $cache The cache instance used for caching data.
*/
private $cache;
/**
* @var string ENDPOINT The directory where files are uploaded.
*/
private const ENDPOINT = '/files';
/**
* @var string UPLOAD_FOLDER_NAME The name of the folder where files are uploaded.
*/
private const UPLOAD_FOLDER_NAME = 'uploads';
/**
* @var int MIN_REQUIRED_DISK_SPACE The minimum required disk space for file uploads in bytes.
*/
private const MIN_REQUIRED_DISK_SPACE = 104857600; // 100MB in bytes
/**
* @var string $dir The directory where files are uploaded.
*/
private $dir;
/**
* @var array PHP_FILE_UPLOAD_ERRORS An array mapping PHP file upload error codes to error messages.
*/
private const PHP_FILE_UPLOAD_ERRORS = [
0 => 'There is no error, the file uploaded with success',
1 => 'The uploaded file exceeds the upload_max_filesize directive',
2 => 'The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the HTML form',
3 => 'The uploaded file was only partially uploaded',
4 => 'No file was uploaded',
6 => 'Missing a temporary folder',
7 => 'Failed to write file to disk.',
8 => 'A PHP extension stopped the file upload.',
];
/**
* @var array MIME_WHITE_LIST An array of allowed MIME types for file uploads.
*/
private const MIME_WHITE_LIST = [
'image/*', // Images
'video/*', // Videos
'audio/*', // Audios
'application/pdf', // PDF
'application/x-zip-compressed', // ZIP
'application/zip', // ZIP
'application/msword', // DOC
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // DOCX
'application/vnd.ms-excel', // XLS
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // XLSX
'application/vnd.ms-powerpoint', // PPT
'application/vnd.openxmlformats-officedocument.presentationml.presentation', // PPTX
'application/xml', // XML
'text/xml', // XML
'application/json', // JSON
'text/csv', // CSV
];
/**
* FileManagerController constructor.
*
* This constructor initializes the FileManagerController by setting up the default directory,
* initializing the responder and cache instances, and registering the routes for file-related operations.
*
* @param Router $router The router instance used to register routes.
* @param Responder $responder The responder instance used to send responses.
* @param GenericDB $db The database instance used for database operations.
* @param ReflectionService $reflection The reflection service instance used for column reflection.
* @param Cache $cache The cache instance used for caching data.
*/
public function __construct(Router $router, Responder $responder, GenericDB $db, ReflectionService $reflection, Cache $cache)
{
$this->dir = __DIR__ . DIRECTORY_SEPARATOR . $this::UPLOAD_FOLDER_NAME;
$this->validateDefaultDir();
$this->responder = $responder;
$this->cache = $cache;
$router->register('GET', $this::ENDPOINT, array($this, '_initFileRequest'));
$router->register('GET', $this::ENDPOINT . '/limits', array($this, '_initLimits'));
$router->register('GET', $this::ENDPOINT . '/view', array($this, '_initFileView'));
$router->register('GET', $this::ENDPOINT . '/download', array($this, '_initFileDownload'));
$router->register('GET', $this::ENDPOINT . '/stats', array($this, '_initStats'));
$router->register('GET', $this::ENDPOINT . '/img_resize', array($this, '_initImgResize'));
$router->register('GET', $this::ENDPOINT . '/img_cpr', array($this, '_initImgCompress'));
$router->register('POST', $this::ENDPOINT . '/upload', array($this, '_initFileUpload'));
$router->register('POST', $this::ENDPOINT . '/move', array($this, '_initFileMove'));
$router->register('POST', $this::ENDPOINT . '/rename', array($this, '_initFileRename'));
$router->register('POST', $this::ENDPOINT . '/copy', array($this, '_initFileCopy'));
$router->register('DELETE', $this::ENDPOINT . '/delete', array($this, '_initFileDelete'));
}
/**
* Retrieves statistics about the files and folders in the default directory.
*
* This method calculates the total size, number of files, and number of folders
* in the default directory. It returns a response containing these statistics.
*
* @param ServerRequestInterface $request The server request instance.
* @return ResponseInterface The response containing the statistics of the directory.
*/
public function _initStats(ServerRequestInterface $request): ResponseInterface
{
$total_size = 0;
$total_files = 0;
$total_folders = 0;
$directoryIterator = new RecursiveDirectoryIterator($this->dir, RecursiveDirectoryIterator::SKIP_DOTS);
$iterator = new RecursiveIteratorIterator($directoryIterator, RecursiveIteratorIterator::SELF_FIRST);
foreach ($iterator as $file) {
if ($file->isFile()) {
$total_size += $file->getSize();
$total_files++;
} elseif ($file->isDir()) {
$total_folders++;
}
}
$total_size = $this->formatFileSize($total_size);
return $this->responder->success([
'total_files' => $total_files,
'total_folders' => $total_folders,
'total_size' => $total_size,
]);
}
/**
* Handles a file list request.
*
* This method processes a request to view the contents of a specified directory. It validates the input parameters,
* checks if the directory exists, and returns the list of files in the directory. If the directory is not found,
* it returns an appropriate error response.
*
* @param ServerRequestInterface $request The server request containing query parameters.
* @return ResponseInterface The response containing the list of files in the directory or an error message.
*
* Query Parameters:
* - dir (string, optional): The directory to view. Defaults to the root directory.
* - with_md5 (bool, optional): Whether to include the MD5 hash of the files in the response. Defaults to false.
* - recursive (bool, optional): Whether to recursively list files in subdirectories. Defaults to false.
*
* @throws Exception If there is an error during the file request process.
*/
public function _initFileRequest(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getQueryParams();
$requested_dir = $body['dir'] ?? null;
$with_md5 = $body['with_md5'] ?? false;
$recursive = $body['recursive'] ?? false;
if ($requested_dir !== null) {
$requested_dir = str_replace('/', DIRECTORY_SEPARATOR, $requested_dir);
}
$dir = $requested_dir ? $this->dir . DIRECTORY_SEPARATOR . $requested_dir : $this->dir;
$show_dir = $requested_dir ? $requested_dir : 'root';
if (!is_dir($dir)) {
return $this->responder->error(404, 'Directory not found');
} else {
return $this->responder->success(['current_directory' => $show_dir, 'files' => $this->readFiles($dir, $with_md5, $recursive)]);
}
}
/**
* Views a specified file.
*
* This method handles the viewing of a file from the specified directory. It validates the input parameters,
* checks if the file exists, and returns the file as a response for viewing. If the file is not found or
* any error occurs, it returns an appropriate error response.
*
* @param ServerRequestInterface $request The server request containing query parameters.
* @return ResponseInterface The response containing the file for viewing or an error message.
*
* Query Parameters:
* - filename (string): The name of the file to be viewed.
* - filedir (string, optional): The directory of the file to be viewed. Defaults to the root directory.
*
* @throws Exception If there is an error during the file viewing process.
*/
public function _initFileView(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getQueryParams();
$filename = $this->sanitizeFilename($body['filename']) ?? null;
$filedir = $this->sanitizeDir($body['filedir'], true) ?? null;
if ($filename === null) {
return $this->responder->error(400, 'No file specified');
}
$filePath = $filedir . DIRECTORY_SEPARATOR . $filename;
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File not found');
}
$mimeType = mime_content_type($filePath);
$file = file_get_contents($filePath);
$response = ResponseFactory::from(200, $mimeType, $file);
$response = $response->withHeader('Content-Disposition', 'inline; filename=' . $filename);
$response = $response->withHeader('X-Filename', $filename);
return $response;
}
/**
* Handles file upload from the server request.
*
* @param ServerRequestInterface $request The server request containing the uploaded files.
* @return ResponseInterface The response indicating the result of the file upload process.
*
* The method performs the following steps:
* - Retrieves the uploaded files from the request.
* - Checks if any file is uploaded, returns an error response if no file is uploaded.
* - Parses the request body to get the directory path and compression options.
* - Creates the directory if it does not exist.
* - Processes each uploaded file:
* - Checks for upload errors.
* - Verifies memory limit for the file size.
* - Sanitizes the filename.
* - Verifies the MIME type of the file.
* - Checks if the file already exists in the directory.
* - If image compression is enabled and the file is an image, compresses the image and saves it as a .webp file.
* - Moves the uploaded file to the target directory.
* - Collects the result status for each file, including any errors encountered.
* - Returns a response with the overall result status, including the number of successfully uploaded files and errors.
*/
public function _initFileUpload(ServerRequestInterface $request): ResponseInterface
{
$uploadedFiles = $request->getUploadedFiles();
$uploadedFiles = $uploadedFiles['file'] ?? null;
if ($uploadedFiles === null) {
return $this->responder->error(400, 'No file uploaded.');
}
$body = $request->getParsedBody();
$dir = $this->sanitizeDir($body->dir, true);
$compress_images = filter_var($body->compress_images ?? false, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
$compress_images_quality = $this->sanitizeQualityValue($body->compress_images_quality) ?? 80;
if ($dir === null) {
return $this->responder->error(400, 'Invalid directory specified.');
}
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (!is_array($uploadedFiles)) {
$uploadedFiles = [$uploadedFiles];
}
$result_status = [];
$count = 0;
$total_uploaded_successfully = 0;
foreach ($uploadedFiles as $uploadedFile) {
$count++;
if ($uploadedFile->getError() === UPLOAD_ERR_OK) {
if (!$this->checkMemoryLimit($uploadedFile->getSize())) {
$result_status[$count] = [
'status' => 'ERROR',
'message' => 'Not enough memory to process file, file not uploaded.',
'error' => 'Memory limit would be exceeded',
'file_name' => $uploadedFile->getClientFilename(),
];
continue;
}
$filename = $this->sanitizeFilename($uploadedFile->getClientFilename());
$tmpStream = $uploadedFile->getStream();
$tmpPath = $tmpStream->getMetadata('uri');
$isAllowed = $this->verifyMimeType($tmpPath);
if (!$isAllowed) {
$result_status[$count] = [
'status' => 'ERROR',
'message' => 'Error uploading file',
'error' => 'Invalid file type!',
'file_name' => $uploadedFile->getClientFilename(),
];
continue;
}
if($compress_images && $this->isImage($tmpPath)){
$new_filename = $this->convertFileExtension($filename, 'webp');
if (file_exists($dir . DIRECTORY_SEPARATOR . $new_filename)) {
$result_status[$count] = [
'status' => 'ERROR',
'message' => 'Error uploading file',
'error' => 'File already exists in this directory',
'file_name' => $new_filename,
];
continue;
}
if ($this->isImage($tmpPath)) {
try {
$compressed_image = $this->compressImage($tmpPath, $compress_images_quality);
$newFilePath = $dir . DIRECTORY_SEPARATOR . $new_filename;
$compressed_image->writeImage($newFilePath);
$result_status[$count] = [
'compression_image_status' => 'OK',
'new_file_size' => $this->formatFileSize(filesize($newFilePath)),
'new_file_name' => $new_filename,
'new_file_md5' => md5_file($newFilePath),
'total_savings' => "-" . $this->formatFileSize(filesize($tmpPath) - filesize($newFilePath)),
];
} catch (Exception $e) {
$result_status[$count] = [
'compression_image_status' => 'ERROR',
'message' => 'Error during image compression: ' . $e->getMessage(),
];
}
} else {
$result_status[$count]['compression_image_status'] = "Not compressed, is not an image";
}
} else {
if (file_exists($dir . DIRECTORY_SEPARATOR . $filename)) {
$result_status[$count] = [
'status' => 'ERROR',
'message' => 'Error uploading file',
'error' => 'File already exists in this directory',
'file_name' => $uploadedFile->getClientFilename(),
];
continue;
}
$uploadedFile->moveTo($dir . DIRECTORY_SEPARATOR . $filename);
$result_status[$count] = [
'status' => 'OK',
'message' => 'File uploaded successfully',
'file_name' => $filename,
'file_size' => $this->formatFileSize($uploadedFile->getSize()),
'md5' => md5_file($dir . DIRECTORY_SEPARATOR . $filename),
];
}
$total_uploaded_successfully++;
} else {
$result_status[$count] = [
'status' => 'ERROR',
'message' => 'Error uploading file',
'file_name' => $uploadedFile->getClientFilename(),
'error' => $this::PHP_FILE_UPLOAD_ERRORS[$uploadedFile->getError()],
];
}
}
$result_status['total_uploaded_successfully'] = $total_uploaded_successfully . "/" . $count;
$result_status['total_errors'] = $count - $total_uploaded_successfully;
return $this->responder->success($result_status);
}
/**
* Downloads a specified file.
*
* This method handles the download of a file from the specified directory. It validates the input parameters,
* checks if the file exists, and returns the file as a response for download. If the file is not found or
* any error occurs, it returns an appropriate error response.
*
* @param ServerRequestInterface $request The server request containing query parameters.
* @return ResponseInterface The response containing the file for download or an error message.
*
* Query Parameters:
* - filename (string): The name of the file to be downloaded.
* - filedir (string, optional): The directory of the file to be downloaded. Defaults to the root directory.
*
* @throws Exception If there is an error during the file download process.
*/
public function _initFileDownload(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getQueryParams();
$filename = $this->sanitizeFilename($body['filename']) ?? null;
$filedir = $this->sanitizeDir($body['filedir'], true) ?? null;
if ($filename === null or $filename === "") {
return $this->responder->error(400, 'No file specified');
}
$filePath = $filedir . DIRECTORY_SEPARATOR . $filename;
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File not found');
}
$response = ResponseFactory::from(200, 'application/octet-stream', file_get_contents($filePath));
$response = $response->withHeader('Content-Disposition', 'attachment; filename=' . $filename);
return $response;
}
/**
* Deletes a specified file.
*
* This method deletes a file in the specified directory. It validates the input parameters,
* checks if the file exists, and attempts to delete it. If successful, it returns a success response.
*
* @param ServerRequestInterface $request The server request containing parsed body parameters.
* @return ResponseInterface The response indicating the result of the delete operation.
*
* Parsed Body Parameters:
* - filename (string): The name of the file to be deleted.
* - filedir (string, optional): The directory of the file to be deleted. Defaults to the root directory.
*
* @throws Exception If there is an error during the delete process.
*/
public function _initFileDelete(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getParsedBody();
$filename = $this->sanitizeFilename($body->filename) ?? null;
$filedir = $this->sanitizeDir($body->filedir) ?? null;
if ($filename === null) {
return $this->responder->error(400, 'No file specified');
}
if ($filedir !== null) {
$filedir = str_replace('/', DIRECTORY_SEPARATOR, $filedir);
} else {
$filedir = '';
}
$filePath = $this->dir . DIRECTORY_SEPARATOR . $filedir . DIRECTORY_SEPARATOR . $filename;
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File [' . $filename . '] not found in this directory, nothing deleted');
}
if (!$this->lockFile($filePath)) {
return $this->responder->error(500, 'Unable to lock file for deletion');
}
try {
if (!unlink($filePath)) {
return $this->responder->error(500, 'Error deleting file');
}
return $this->responder->success(['message' => 'File [' . $filename . '] deleted successfully']);
} finally {
$this->unlockFile($filePath);
}
}
/**
* Moves a specified file to a new directory.
*
* This method moves a file from its current directory to a new directory. It validates the input parameters,
* checks if the file exists, and attempts to move it. If successful, it returns a success response.
*
* @param ServerRequestInterface $request The server request containing parsed body parameters.
* @return ResponseInterface The response indicating the result of the move operation.
*
* Parsed Body Parameters:
* - filename (string): The name of the file to be moved.
* - filedir (string, optional): The current directory of the file. Defaults to the root directory.
* - new_dir (string): The new directory to move the file to.
*
* @throws Exception If there is an error during the move process.
*/
public function _initFileMove(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getParsedBody();
$filename = $this->sanitizeFilename($body->filename) ?? null;
$filedir = $this->sanitizeDir($body->filedir) ?? null;
$new_dir = $this->sanitizeDir($body->new_filedir) ?? null;
if ($filename === null) {
return $this->responder->error(400, 'No file specified');
}
if ($new_dir === null) {
return $this->responder->error(400, 'No new directory specified');
} else {
$new_dir = str_replace('/', DIRECTORY_SEPARATOR, $new_dir);
}
if ($filedir !== null) {
$filedir = str_replace('/', DIRECTORY_SEPARATOR, $filedir);
} else {
$filedir = '';
}
$filePath = $this->dir . DIRECTORY_SEPARATOR . $filedir . DIRECTORY_SEPARATOR . $filename;
$newPath = $this->dir . DIRECTORY_SEPARATOR . $new_dir . DIRECTORY_SEPARATOR . $filename;
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File [' . $filename . '] not found, nothing moved');
}
if (file_exists($newPath)) {
return $this->responder->error(409, 'File [' . $filename . '] already exists in [' . $new_dir . ']. Nothing moved.');
}
if (!is_dir($this->dir . DIRECTORY_SEPARATOR . $new_dir)) {
mkdir($this->dir . DIRECTORY_SEPARATOR . $new_dir, 0755, true);
}
if (!$this->lockFile($filePath)) {
return $this->responder->error(500, 'Unable to lock source file');
}
if (!$this->lockFile($newPath)) {
return $this->responder->error(500, 'Unable to lock dest file');
}
try {
if (!rename($filePath, $newPath)) {
return $this->responder->error(500, 'Error moving file');
}
return $this->responder->success(['message' => 'File [' . $filename . '] moved successfully to [' . $new_dir . ']']);
} finally {
$this->unlockFile($filePath);
$this->unlockFile($newPath);
}
}
/**
* Initializes the file copy process.
*
* @param ServerRequestInterface $request The server request containing the file details.
* @return ResponseInterface The response indicating the result of the file copy operation.
*
* The function performs the following steps:
* 1. Parses the request body to get the filename, current directory, and new directory.
* 2. Sanitizes the filename and directory paths.
* 3. Validates the presence of the filename and new directory.
* 4. Constructs the source and destination file paths.
* 5. Checks if the source file exists and if the destination file already exists.
* 6. Creates the new directory if it does not exist.
* 7. Locks the source and destination files to prevent concurrent access.
* 8. Copies the file from the source to the destination.
* 9. Unlocks the files after the copy operation.
* 10. Returns a success response if the file is copied successfully, or an error response if any step fails.
*/
public function _initFileCopy(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getParsedBody();
$filename = $this->sanitizeFilename($body->filename) ?? null;
$filedir = $this->sanitizeDir($body->filedir, true) ?? null;
$new_dir = $this->sanitizeDir($body->new_filedir, true) ?? null;
if ($filename === null) {
return $this->responder->error(400, 'No file specified');
}
if ($new_dir === null) {
return $this->responder->error(400, 'No new directory specified');
}
$filePath = $filedir . DIRECTORY_SEPARATOR . $filename;
$newPath = $new_dir . DIRECTORY_SEPARATOR . $filename;
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File [' . $filename . '] not found in ['. $filePath . '], nothing copied');
}
if (!is_dir($new_dir)) {
mkdir($new_dir, 0755, true);
}
if (file_exists($newPath)) {
return $this->responder->error(409, 'File [' . $filename . '] already exists in [' . $new_dir . ']');
}
// Lock only source file
if (!$this->lockFile($filePath)) {
return $this->responder->error(500, 'Unable to lock source file');
}
try {
if (!copy($filePath, $newPath)) {
return $this->responder->error(500, 'Error copying file');
}
return $this->responder->success(['message' => 'File [' . $filename . '] copied successfully to [' . $new_dir . ']']);
} finally {
$this->unlockFile($filePath);
}
}
/**
* Renames a specified file.
*
* This method renames a file in the specified directory. It validates the input parameters,
* checks if the file exists, and attempts to rename it. If successful, it returns a success response.
*
* @param ServerRequestInterface $request The server request containing parsed body parameters.
* @return ResponseInterface The response indicating the result of the rename operation.
*
* Parsed Body Parameters:
* - filename (string): The current name of the file to be renamed.
* - new_filename (string): The new name for the file.
* - filedir (string, optional): The directory of the file to be renamed. Defaults to the root directory.
*
* @throws Exception If there is an error during the renaming process.
*/
public function _initFileRename(ServerRequestInterface $request): ResponseInterface
{
$body = $request->getParsedBody();
$filename = $this->sanitizeFilename($body->filename) ?? null;
$new_filename = $this->sanitizeFilename($body->new_filename) ?? null;
$filedir = $this->sanitizeDir($body->filedir) ?? '';
if ($filename === null) {
return $this->responder->error(400, 'No file specified');
}
if ($new_filename === null) {
return $this->responder->error(400, 'No new filename specified');
}
$filePath = $this->dir . DIRECTORY_SEPARATOR . $filedir . DIRECTORY_SEPARATOR . $filename;
$newPath = $this->dir . DIRECTORY_SEPARATOR . $filedir . DIRECTORY_SEPARATOR . $new_filename;
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File [' . $filename . '] not found, nothing renamed');
}
if (file_exists($newPath)) {
return $this->responder->error(409, 'File [' . $new_filename . '] already exists in this directory. Nothing renamed.');
}
if (!$this->lockFile($filePath)) {
return $this->responder->error(500, 'Unable to lock source file');
}
try {
if (!rename($filePath, $newPath)) {
return $this->responder->error(500, 'Error renaming file');
}
return $this->responder->success(['message' => 'File [' . $filename . '] renamed successfully to [' . $new_filename . ']']);
} finally {
$this->unlockFile($newPath);
}
}
/**
* Resizes an image to the specified dimension.
*
* This method checks if the Imagick extension is enabled, validates the input parameters,
* and resizes the specified image file to the desired dimension. The resized image
* is cached to improve performance for subsequent requests.
*
* @param ServerRequestInterface $request The server request containing query parameters.
* @return ResponseInterface The response containing the resized image or an error message.
*
* Query Parameters:
* - filedir (string): The directory of the file to be resized.
* - filename (string): The name of the file to be resized.
* - dimension (string): The dimension to resize ('width' or 'height').
* - dimension_value (int): The value of the dimension to resize to.
*
* @throws ImagickException If there is an error during image resizing.
*/
public function _initImgResize(ServerRequestInterface $request): ResponseInterface
{
if (!extension_loaded('imagick')) {
return $this->responder->error(500, 'Imagick extension is not enabled');
}
$body = $request->getQueryParams();
$filedir = $this->sanitizeDir($body['filedir']) ?? null;
$filename = $this->sanitizeFilename($body['filename']) ?? null;
$dimension = $this->sanitizeDimension($body['dimension']) ?? null;
$dimension_value = $this->sanitizeDimensionValue($body['dimension_value']) ?? null;
if ($filedir !== null) {
$filedir = str_replace('/', DIRECTORY_SEPARATOR, $filedir);
} else {
$filedir = '';
}
if ($filename === null) {
return $this->responder->error(400, 'No file specified');
}
if ($dimension === null) {
return $this->responder->error(400, 'No valid dimension specified');
}
if ($dimension_value === null) {
return $this->responder->error(400, 'No dimension value specified');
}
$filePath = $this->dir . DIRECTORY_SEPARATOR . $filedir . DIRECTORY_SEPARATOR . $filename;
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File [' . $filename . '] not found, nothing resized');
}
if (!$this->isImage($filePath)) {
return $this->responder->error(400, 'File is not an image');
}
$fileHash = md5_file($filePath);
$cacheKey = "resize_{$filename}_{$dimension}_{$dimension_value}_{$fileHash}";
if ($this->cache->get($cacheKey)) {
$imageData = $this->cache->get($cacheKey);
} else {
try {
$resized_img = $this->resizeImage($filePath, $dimension, $dimension_value);
$imageData = $resized_img->getImageBlob();
$this->cache->set($cacheKey, $imageData);
} catch (ImagickException $e) {
return $this->responder->error(500, 'Error resizing image: ' . $e->getMessage());
}
}
$response = ResponseFactory::from(200, 'image', $imageData);
$response = $response->withHeader('Content-Length', strlen($imageData));
$response = $response->withHeader('Content-Disposition', 'inline; filename=' . $filename);
return $response;
}
/**
* Initializes image compression.
*
* This method checks if the Imagick extension is enabled, validates the input parameters,
* and compresses the specified image file to the desired quality. The compressed image
* is cached to improve performance for subsequent requests.
*
* @param ServerRequestInterface $request The server request containing query parameters.
* @return ResponseInterface The response containing the compressed image or an error message.
*
* Query Parameters:
* - filedir (string): The directory of the file to be compressed.
* - filename (string): The name of the file to be compressed.
* - quality (int): The quality of the compressed image (default is 80).
*
* @throws ImagickException If there is an error during image compression.
*/
public function _initImgCompress(ServerRequestInterface $request): ResponseInterface
{
if (!extension_loaded('imagick')) {
return $this->responder->error(500, 'Imagick extension is not enabled');
}
$body = $request->getQueryParams();
$filedir = $this->sanitizeDir($body['filedir']) ?? '';
$filename = $this->sanitizeFilename($body['filename']) ?? null;
$quality = $this->sanitizeQualityValue($body['quality']) ?? 80;
if ($filename === null) {
return $this->responder->error(400, 'No file specified');
}
$filePath = $this->dir . DIRECTORY_SEPARATOR . $filedir . DIRECTORY_SEPARATOR . $filename;
$fileHash = md5_file($filePath);
$cacheKey = "compress_{$filename}_{$quality}_{$fileHash}";
if (!file_exists($filePath)) {
return $this->responder->error(404, 'File [' . $filename . '] not found in this directory, nothing compressed');
}
if (!$this->isImage($filePath)) {
return $this->responder->error(400, 'File is not an image');
}
if ($this->cache->get($cacheKey)) {
$imageData = $this->cache->get($cacheKey);
} else {
try {
$compressed_img = $this->compressImage($filePath, $quality);
$imageData = $compressed_img->getImageBlob();
$this->cache->set($cacheKey, $imageData);
} catch (ImagickException $e) {
return $this->responder->error(500, 'Error compressing image: ' . $e->getMessage());
}
}
$response = ResponseFactory::from(200, 'image/webp', $imageData);
$response = $response->withHeader('Content-Length', strlen($imageData));
$response = $response->withHeader('Content-Disposition', 'inline; filename=' . $filename);
return $response;
}
/**
* Initializes the limits for file uploads based on server configuration.
*
* This method calculates the maximum file upload size by taking the minimum value
* between 'upload_max_filesize' and 'post_max_size' from the PHP configuration.
* It then returns a response with the maximum size in bytes, a formatted version
* of the maximum size, and a list of allowed MIME types.
*
* @param ServerRequestInterface $request The server request instance.
* @return ResponseInterface The response containing the upload limits and allowed MIME types.
*/
public function _initLimits(ServerRequestInterface $request): ResponseInterface
{
$maxBytes = min(
$this->convertToBytes(ini_get('upload_max_filesize')),
$this->convertToBytes(ini_get('post_max_size'))
);
return $this->responder->success([
'max_size' => $maxBytes,
'max_size_formatted' => $this->formatFileSize($maxBytes),
'mime_types' => $this::MIME_WHITE_LIST,
]);
}
/**
* Validates the default directory path.
*
* This method performs several checks to ensure that the default directory path is valid:
* - Checks if the path is empty.
* - Attempts to create the directory if it does not exist.
* - Verifies that the path is a directory.
* - Checks if the directory is readable and writable.
* - Attempts to write and delete a test file in the directory.
*
* @return bool|ResponseInterface Returns true if the directory is valid, otherwise returns an error response.
*/
public function validateDefaultDir(): bool | ResponseInterface
{
// Check if the path is empty
if (empty($this->dir)) {
return $this->responder->error(403, 'The default directory path cannot be empty. Config one first.');
}
$minRequiredSpace = $this::MIN_REQUIRED_DISK_SPACE;
$freeSpace = disk_free_space($this->dir);
if ($freeSpace === false) {
return $this->responder->error(500, "Cannot determine free space on disk.");
}
if ($freeSpace < $minRequiredSpace) {
return $this->responder->error(500, sprintf(
"Insufficient disk space. At least %s required, %s available",
$this->formatFileSize($minRequiredSpace),
$this->formatFileSize($freeSpace)
));
}
// If the directory does not exist, try to create it
if (!file_exists($this->dir)) {
try {
if (!mkdir($this->dir, 0755, true)) {
return $this->responder->error(403, "Unable to create the default directory: " . $this->dir);
}
// Check that the permissions have been set correctly
chmod($this->dir, 0755);
} catch (Exception $e) {
return $this->responder->error(500, "Error creating the default directory: " . $e->getMessage());
}
}
// Check that it is a directory
if (!is_dir($this->dir)) {
return $this->responder->error(403, "The default dir path exists but is not a directory: " . $this->dir);
}
// Check permissions
if (!is_readable($this->dir)) {
return $this->responder->error(403, "The default directory is not readable: " . $this->dir);
}
if (!is_writable($this->dir)) {
return $this->responder->error(403, "The default directory is not writable: " . $this->dir);
}
// Check if we can actually write a test file
$testFile = $this->dir . DIRECTORY_SEPARATOR . '.write_test';
try {
if (file_put_contents($testFile, '') === false) {
return $this->responder->error(403, "Unable to write to the default directory.");
}
unlink($testFile);
} catch (Exception $e) {
return $this->responder->error(500, "Write test failed on default directory: " . $e->getMessage());
}
if (!$this->generateSecurityServerFile()) {
return $this->responder->error(500, "Error generating security file in the default directory.");
}
return true;
}
private function generateSecurityServerFile(): bool
{
$serverSoftware = strtolower($_SERVER['SERVER_SOFTWARE'] ?? '');
try {
if (strpos($serverSoftware, 'apache') !== false) {
return $this->generateApacheSecurityFile();
} elseif (strpos($serverSoftware, 'nginx') !== false) {
return $this->generateNginxSecurityFile();
}
return $this->generateApacheSecurityFile();
} catch (Exception $e) {
return false;
}
}
private function generateApacheSecurityFile(): bool
{
$securityFile = __DIR__ . DIRECTORY_SEPARATOR . '.htaccess';
$newContent = "# BEGIN PHP CRUD API FILE MANAGER\n" .
'<Directory "/' . $this::UPLOAD_FOLDER_NAME . '">' . "\n" .
' Options -Indexes' . "\n" .
' Order deny,allow' . "\n" .
' Deny from all' . "\n" .
'</Directory>' . "\n" .
"# END PHP CRUD API FILE MANAGER";
return $this->appendConfigIfNotExists($securityFile, $newContent);
}
private function generateNginxSecurityFile(): bool
{
$securityFile = __DIR__ . DIRECTORY_SEPARATOR . 'nginx.conf';
$newContent = "# BEGIN PHP CRUD API FILE MANAGER\n" .
'location /' . $this::UPLOAD_FOLDER_NAME . ' {' . "\n" .
' deny all;' . "\n" .
' autoindex off;' . "\n" .
'}' . "\n" .
"# END PHP CRUD API FILE MANAGER";
return $this->appendConfigIfNotExists($securityFile, $newContent);
}
private function appendConfigIfNotExists(string $filePath, string $newContent): bool
{
if (file_exists($filePath)) {
$currentContent = file_get_contents($filePath);
if (strpos($currentContent, $newContent) !== false) {
return true; // Configuration already exists
}
return file_put_contents($filePath, $currentContent . "\n" . $newContent) !== false;
}
return file_put_contents($filePath, $newContent) !== false;
}
/**
* Reads the files in the specified directory and returns an array of file information.
*
* @param string $dir The directory to read files from. If null, the default directory will be used.
* @param bool $with_md5 Whether to include the MD5 hash of the files in the returned array.
* @param bool $recursive Whether to read files recursively from subdirectories.
* @return array An array of file information. Each file information includes:
* - name: The name of the file.
* - type: The MIME type of the file.
* - path: The web path to the file.
* - size: The formatted size of the file (only for files, not directories).
* - created_on: The creation date of the file.
* - modified_on: The last modified date of the file.
* - md5: The MD5 hash of the file (if $with_md5 is true).
* - files: An array of files within the directory (if the file is a directory).
* @throws Exception If the directory cannot be opened.
*/
public function readFiles($dir, $with_md5, $recursive): array
{
$dir = $dir ?? $this->dir;
if (!is_dir($dir)) {
return ["Error: dir requested not found"];
}
$files = [];
$current_dir = @opendir($dir);
if ($current_dir === false) {
throw new Exception("Impossibile aprire la directory: {$dir}");
}
$isEmpty = true;
while (($file = readdir($current_dir)) !== false) {
if ($file === '.' || $file === '..') {
continue;
}
$isEmpty = false;
$filePath = $dir . DIRECTORY_SEPARATOR . $file;
$viewWebPath = $this->getPublicUrl($file, 'view', $dir);
$downloadWebPath = $this->getPublicUrl($file, 'download', $dir);
try {
$size = filesize($filePath);
$formattedSize = $this->formatFileSize($size);
// Get MIME type
$mimeType = mime_content_type($filePath) ?: 'application/octet-stream';
if (is_dir($filePath)) {
$files[] = [
'name' => $file,
'type' => $mimeType,
'created_on' => date('Y-m-d H:i:s', filectime($filePath)),
'modified_on' => date('Y-m-d H:i:s', filemtime($filePath)),
'files' => $recursive ? $this->readFiles($filePath, $with_md5, $recursive) : 'Request recursivity to view files',
];
} else {
$fileData = [
'name' => $file,
'type' => $mimeType,
'view_url' => $viewWebPath,
'download_url' => $downloadWebPath,
'size' => $formattedSize,
'created_on' => date('Y-m-d H:i:s', filectime($filePath)),
'modified_on' => date('Y-m-d H:i:s', filemtime($filePath)),
];
if ($with_md5) {
$fileData['md5'] = md5_file($filePath);
}
$files[] = $fileData;
}
} catch (Exception $e) {
continue; // Skip files causing errors
}
}
closedir($current_dir);
if ($isEmpty) {
return ["0: Empty directory"];
}
sort($files);
return $files;
}
/**
* Formats a file size in bytes to a human-readable format.
*
* @param int $size The file size in bytes.
* @return string The formatted file size.
*/
public function formatFileSize(int $size): string
{
$units = ['bytes', 'KB', 'MB', 'GB'];
$power = $size > 0 ? floor(log($size, 1024)) : 0;
$formattedSize = number_format($size / pow(1024, $power), 2) . ' ' . $units[$power];
return $formattedSize;
}
/**
* Resizes an image to the specified dimension.
*
* @param string $img_src The source path of the image to be resized.
* @param string $dimension The dimension to resize ('width' or 'height').
* @param int $dimension_value The value of the dimension to resize to.
* @return bool|Imagick|ResponseInterface Returns the resized Imagick object on success, false on failure, or a ResponseInterface on invalid dimension.
* @throws ImagickException If an error occurs during image processing.
*/
public function resizeImage($img_src, $dimension, $dimension_value): bool | Imagick | ResponseInterface
{
try {
// Crea un nuovo oggetto Imagick
$image = new Imagick($img_src);
// Ottieni le dimensioni originali dell'immagine
$originalWidth = $image->getImageWidth();
$originalHeight = $image->getImageHeight();
// Calcola le nuove dimensioni
if ($dimension == 'width') {
$newWidth = ceil($dimension_value);
$newHeight = ceil(($originalHeight / $originalWidth) * $newWidth);
} elseif ($dimension == 'height') {
$newHeight = ceil($dimension_value);
$newWidth = ceil(($originalWidth / $originalHeight) * $newHeight);
} else {
return $this->responder->error(400, 'Invalid dimension specified');
}
// Ridimensiona l'immagine
$image->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1);
return $image;
} catch (ImagickException $e) {
echo "Errore: " . $e->getMessage();
return false;
}
}
/**
* Compresses an image by reducing its quality and converting it to the WebP format.
*
* @param string $img_src The path to the source image file.
* @param int|string $quality The quality level for the compressed image (default is 80).
* @return bool|Imagick Returns the compressed Imagick object on success, or false on failure.
* @throws ImagickException If an error occurs during image processing.
*/
public function compressImage($img_src, $quality = '80'): bool | Imagick
{
try {
$image = new Imagick($img_src);
$image->stripImage();
$image->setImageCompressionQuality($quality);
$image->setImageFormat('webp');
return $image;
} catch (ImagickException $e) {
echo "Errore: " . $e->getMessage();
return false;
}
}
/**
* Checks if the given file path points to a valid image.
*
* @param string $filePath The path to the file to check.
* @return bool True if the file is an image, false otherwise.
*/
public function isImage($filePath): bool
{
$imageInfo = @getimagesize($filePath);
if ($imageInfo === false) {
return false;
}
$mimeType = $imageInfo['mime'];
if (strpos($mimeType, 'image/') !== 0) {
return false;
}
return true;
}
/**
* Convert a shorthand byte value from a PHP configuration directive to an integer value.
*
* @param string $value The shorthand byte value (e.g., '2M', '512K').
* @return int The byte value as an integer.
*/
private function convertToBytes(string $val): int
{
if (empty($val)) {
return 0;
}
$val = trim($val);
$last = strtolower($val[strlen($val) - 1]);
$multiplier = 1;
switch ($last) {
case 'g':
$multiplier = 1024 * 1024 * 1024;
break;
case 'm':
$multiplier = 1024 * 1024;
break;
case 'k':
$multiplier = 1024;
break;
default:
if (!is_numeric($last)) {
$val = substr($val, 0, -1);
}
break;
}
return max(0, (int) $val * $multiplier);
}
/**
* Generates a public URL for a specified file.
*
* @param string|null $dir The directory of the file (optional).
* @param string $filename The name of the file.
* @param string $type The type of operation (default 'view').
* @return string The generated public URL.
*/
private function getPublicUrl(string $filename, string $type = 'view', ?string $dir = null): string
{
$base = $_SERVER['HTTP_HOST'] . $_SERVER['SCRIPT_NAME'];
$publicPath = $base . $this::ENDPOINT . '/' . $type . '?filename=' . urlencode($filename);
if ($dir !== null) {
$dir = str_replace(DIRECTORY_SEPARATOR, '/', $dir);
$pos = strpos($dir, $this::UPLOAD_FOLDER_NAME);
if ($pos !== false) {
$dir = substr($dir, $pos + strlen($this::UPLOAD_FOLDER_NAME));
}
if ($dir !== '') {
$publicPath .= '&filedir=' . urlencode($dir);
}
}
return $publicPath;
}
/**
* Sanitize a directory path to ensure it is safe and valid.
*
* This method normalizes directory separators, removes unsafe characters,
* and ensures the path does not traverse outside the root directory.
*
* @param string|null $path The directory path to sanitize. If null or empty, returns the root directory.
* @param bool $full Whether to return the full path or just the sanitized relative path.
* @return string The sanitized directory path. If the path is invalid, returns the root directory or null.
*/
private function sanitizeDir(?string $path, bool $full = false): string {
// Input validation
if ($path === null || trim($path) === '') {
return $full ? $this->dir . DIRECTORY_SEPARATOR : null;
}
// Normalize separators and remove leading/trailing spaces
$path = trim(str_replace(['\\', '/'], DIRECTORY_SEPARATOR, $path));
// Remove directory traversal sequences
$path = preg_replace('/\.{2,}/', '', $path);
// Keep only safe characters for directory names
// [a-zA-Z0-9] - alphanumeric characters
// [\-\_] - dashes and underscores
// [\s] - spaces
// [' . preg_quote(DIRECTORY_SEPARATOR) . '] - directory separator
$path = preg_replace('/[^a-zA-Z0-9\-\_\s' . preg_quote(DIRECTORY_SEPARATOR) . ']/u', '', $path);
// Remove multiple consecutive separators
$path = preg_replace('/' . preg_quote(DIRECTORY_SEPARATOR) . '{2,}/', DIRECTORY_SEPARATOR, $path);
// Remove leading/trailing separators
$path = trim($path, DIRECTORY_SEPARATOR);
// Build full path
$fullPath = $this->dir . DIRECTORY_SEPARATOR . $path;
// Verify path does not escape the root
if (strpos($fullPath, $this->dir) !== 0) {
return $full ? $this->dir . DIRECTORY_SEPARATOR : null;
}
return $full ? $fullPath : $path;
}
private function sanitizeFilename($filename): array | string | null
{
if ($filename === null) {
return null;
} else {
strval($filename);
}
$filename = preg_replace('/[^a-zA-Z0-9\-\_\.\s]/', '', $filename);
return $filename;
}
private function sanitizeDimension($dimension): string | null
{
$dimension = strval($dimension);
$dimension = strtolower($dimension);
return in_array($dimension, ['width', 'height']) ? $dimension : null;
}
private function sanitizeDimensionValue($dimension_value): int | null
{
$dimension_value = intval($dimension_value);
$formatted = filter_var(
$dimension_value,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1]]
);
return $formatted !== false ? $formatted : null;
}
private function sanitizeQualityValue($quality_value): int | null
{
$quality_value = intval($quality_value);
$formatted = filter_var(
$quality_value,
FILTER_VALIDATE_INT,
['options' => ['min_range' => 1, 'max_range' => 100]]
);
return $formatted !== false ? $formatted : null;
}
private function verifyMimeType($filepath): bool
{
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $filepath);
finfo_close($finfo);
return $this->isMimeTypeAllowed($mimeType);
}
private function isMimeTypeAllowed(string $mimeType): bool
{
foreach ($this::MIME_WHITE_LIST as $allowedType) {
$pattern = '#^' . str_replace('*', '.*', $allowedType) . '$#';
if (preg_match($pattern, $mimeType)) {
return true;
}
}
return false;
}
/**
* Checks if there is enough memory available to process a file of the given size.
*
* @param int $fileSize The size of the file in bytes
* @return bool True if there is enough memory, false otherwise
*/
private function checkMemoryLimit(int $fileSize): bool
{
$memoryLimit = $this->convertToBytes(ini_get('memory_limit'));
$currentMemory = memory_get_usage();
$neededMemory = $fileSize * 2.2; // Factor 2.2 for safe margin
return ($currentMemory + $neededMemory) < $memoryLimit;
}
/**
* Locks a file for exclusive access.
*
* @param string $path The path to the file to lock.
* @return bool True if the file was successfully locked, false otherwise.
*/
private function lockFile(string $path): bool {
$fileHandle = fopen($path, 'r+');
if ($fileHandle === false) {
return false;
}
if (!flock($fileHandle, LOCK_EX)) {
fclose($fileHandle);
return false;
}
return true;
}
/**
* Unlocks a file.
*
* @param string $path The path to the file to unlock.
* @return bool True if the file was successfully unlocked, false otherwise.
*/
private function unlockFile(string $path): bool
{
$fileHandle = fopen($path, 'r+');
if ($fileHandle === false) {
return false;
}
$result = flock($fileHandle, LOCK_UN);
fclose($fileHandle);
return $result;
}
/**
* Converts the file extension of a given filename to a new extension.
*
* @param string $filename The name of the file whose extension is to be changed.
* @param string $newExtension The new extension to be applied to the file.
* @return string The filename with the new extension.
*/
private function convertFileExtension(string $filename, string $newExtension): string
{
$pathInfo = pathinfo($filename);
return $pathInfo['filename'] . '.' . $newExtension;
}
}
}