Skip to content

FileManager custom middleware :: IDEA :: **to be completed/tested/debugged** #1054

@Michediana

Description

@Michediana

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!

⚠️ DISCLAIMER: You may find bugs here and there ⚠️

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

Metadata

Metadata

Assignees

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions