-
Notifications
You must be signed in to change notification settings - Fork 1k
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; } } }