Skip to content

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

Open
@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