FileUploadHandler.php

14.00 KB
29/07/2025 16:43
PHP
FileUploadHandler.php
<?php

/**
 * FileUploadHandler class for secure file upload processing
 * Handles file validation, unique filename generation, and WebP conversion
 */
class FileUploadHandler {
    private $uploadDir;
    private $maxFileSize;
    private $allowedTypes;
    private $imageProcessor;
    private $dataManager;

    /**
     * Constructor - Initialize upload handler with configuration
     * @param array $config Configuration array
     * @param ImageProcessor $imageProcessor Image processing instance
     * @param DataManager $dataManager Data management instance
     */
    public function __construct($config, ImageProcessor $imageProcessor, DataManager $dataManager) {
        $this->uploadDir = rtrim($config['upload_dir'] ?? 'albums', '/');
        $this->maxFileSize = $config['upload_max_file_size'] ?? 10485760; // 10MB default
        $this->allowedTypes = $config['upload_allowed_types'] ?? [
            'image/jpeg', 'image/png', 'image/gif', 'image/bmp', 'image/tiff', 'image/webp'
        ];

        $this->imageProcessor = $imageProcessor;
        $this->dataManager = $dataManager;
    }

    /**
     * Handle file upload for multiple files
     * @param int $albumId Target album ID
     * @param array $files $_FILES array or single file array
     * @return array Upload results with success/error information
     */
    public function handleUpload($albumId, $files) {
        $results = [];

        try {
            // Validate album exists
            $album = $this->dataManager->getAlbum($albumId);
            if (!$album) {
                throw new Exception("Album with ID {$albumId} not found");
            }

            // Ensure album directory exists
            $albumDir = $this->uploadDir . '/' . $albumId;
            if (!is_dir($albumDir)) {
                if (!mkdir($albumDir, 0755, true)) {
                    throw new Exception("Failed to create album directory: {$albumDir}");
                }
            }

            // Handle single file or multiple files
            if (isset($files['tmp_name']) && !is_array($files['tmp_name'])) {
                // Single file upload
                $results[] = $this->processUploadedFile($files, $albumId);
            } else {
                // Multiple file upload
                $fileCount = count($files['tmp_name']);
                for ($i = 0; $i < $fileCount; $i++) {
                    $singleFile = [
                        'name' => $files['name'][$i],
                        'type' => $files['type'][$i],
                        'tmp_name' => $files['tmp_name'][$i],
                        'error' => $files['error'][$i],
                        'size' => $files['size'][$i]
                    ];
                    $results[] = $this->processUploadedFile($singleFile, $albumId);
                }
            }

            return [
                'success' => true,
                'results' => $results,
                'total_files' => count($results),
                'successful_uploads' => count(array_filter($results, function($r) { return $r['success']; }))
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'error' => $e->getMessage(),
                'results' => $results
            ];
        }
    }

    /**
     * Process a single uploaded file
     * @param array $file Single file from $_FILES
     * @param int $albumId Target album ID
     * @return array Processing result
     */
    public function processUploadedFile($file, $albumId) {
        try {
            // Validate file upload
            $validation = $this->validateFile($file);
            if (!$validation['valid']) {
                return [
                    'success' => false,
                    'filename' => $file['name'],
                    'error' => $validation['error']
                ];
            }

            // Generate unique filename
            $uniqueFilename = $this->generateUniqueFilename($file['name'], $albumId);
            $tempPath = $file['tmp_name'];
            $finalPath = $this->uploadDir . '/' . $albumId . '/' . $uniqueFilename;

            // Validate image and get metadata
            $imageInfo = $this->imageProcessor->validateImage($tempPath);

            // Convert to WebP and resize
            $conversionResult = $this->imageProcessor->convertToWebP($tempPath, $finalPath);

            // Generate thumbnail
            $thumbnailPath = $this->uploadDir . '/' . $albumId . '/thumb_' . $uniqueFilename;
            $thumbnailResult = $this->imageProcessor->generateThumbnail($finalPath, $thumbnailPath);

            // Add photo to database
            $photoData = [
                'filename' => $uniqueFilename,
                'original_filename' => $file['name'],
                'file_size' => $conversionResult['file_size'],
                'width' => $conversionResult['new_width'],
                'height' => $conversionResult['new_height']
            ];

            $photoId = $this->dataManager->addPhoto($albumId, $photoData);

            return [
                'success' => true,
                'photo_id' => $photoId,
                'filename' => $uniqueFilename,
                'original_filename' => $file['name'],
                'file_size' => $conversionResult['file_size'],
                'dimensions' => [
                    'original' => [
                        'width' => $imageInfo['width'],
                        'height' => $imageInfo['height']
                    ],
                    'processed' => [
                        'width' => $conversionResult['new_width'],
                        'height' => $conversionResult['new_height']
                    ]
                ],
                'thumbnail_generated' => true,
                'conversion_applied' => true
            ];

        } catch (Exception $e) {
            return [
                'success' => false,
                'filename' => $file['name'] ?? 'unknown',
                'error' => $e->getMessage()
            ];
        }
    }

    /**
     * Validate uploaded file
     * @param array $file File array from $_FILES
     * @return array Validation result with valid flag and error message
     */
    public function validateFile($file) {
        // Check for upload errors
        if ($file['error'] !== UPLOAD_ERR_OK) {
            return [
                'valid' => false,
                'error' => $this->getUploadErrorMessage($file['error'])
            ];
        }

        // Check if file was actually uploaded
        if (!is_uploaded_file($file['tmp_name'])) {
            return [
                'valid' => false,
                'error' => 'File was not uploaded via HTTP POST'
            ];
        }

        // Check file size
        if ($file['size'] > $this->maxFileSize) {
            return [
                'valid' => false,
                'error' => 'File size exceeds maximum allowed size of ' . $this->formatBytes($this->maxFileSize)
            ];
        }

        // Check file size is not zero
        if ($file['size'] === 0) {
            return [
                'valid' => false,
                'error' => 'File is empty'
            ];
        }

        // Validate MIME type
        $mimeType = mime_content_type($file['tmp_name']);
        if (!$mimeType || !in_array($mimeType, $this->allowedTypes)) {
            return [
                'valid' => false,
                'error' => 'File type not allowed. Allowed types: ' . implode(', ', $this->allowedTypes)
            ];
        }

        // Additional security check - verify file extension matches MIME type
        $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        $expectedExtensions = $this->getMimeTypeExtensions($mimeType);

        if (!in_array($extension, $expectedExtensions)) {
            return [
                'valid' => false,
                'error' => 'File extension does not match file type'
            ];
        }

        // Validate that it's actually an image using getimagesize
        $imageInfo = getimagesize($file['tmp_name']);
        if ($imageInfo === false) {
            return [
                'valid' => false,
                'error' => 'File is not a valid image'
            ];
        }

        return [
            'valid' => true,
            'mime_type' => $mimeType,
            'extension' => $extension,
            'image_info' => $imageInfo
        ];
    }

    /**
     * Generate unique filename to prevent conflicts
     * @param string $originalName Original filename
     * @param int $albumId Album ID
     * @return string Unique filename with .webp extension
     */
    private function generateUniqueFilename($originalName, $albumId) {
        // Get base name without extension
        $baseName = pathinfo($originalName, PATHINFO_FILENAME);

        // Sanitize filename - remove special characters and spaces
        $baseName = preg_replace('/[^a-zA-Z0-9_-]/', '_', $baseName);
        $baseName = trim($baseName, '_');

        // Ensure filename is not empty
        if (empty($baseName)) {
            $baseName = 'image';
        }

        // Limit length
        $baseName = substr($baseName, 0, 50);

        $albumDir = $this->uploadDir . '/' . $albumId;
        $extension = '.webp'; // All images are converted to WebP
        $counter = 0;

        // Ensure album directory exists for file existence check
        if (!is_dir($albumDir)) {
            mkdir($albumDir, 0755, true);
        }

        do {
            if ($counter === 0) {
                $filename = $baseName . $extension;
            } else {
                $filename = $baseName . '_' . $counter . $extension;
            }
            $counter++;
        } while (file_exists($albumDir . '/' . $filename));

        return $filename;
    }

    /**
     * Get expected file extensions for a MIME type
     * @param string $mimeType MIME type
     * @return array Array of expected extensions
     */
    private function getMimeTypeExtensions($mimeType) {
        $mimeToExtensions = [
            'image/jpeg' => ['jpg', 'jpeg'],
            'image/png' => ['png'],
            'image/gif' => ['gif'],
            'image/bmp' => ['bmp'],
            'image/x-ms-bmp' => ['bmp'],
            'image/tiff' => ['tiff', 'tif'],
            'image/webp' => ['webp']
        ];

        return $mimeToExtensions[$mimeType] ?? [];
    }

    /**
     * Get human-readable upload error message
     * @param int $errorCode PHP upload error code
     * @return string Error message
     */
    private function getUploadErrorMessage($errorCode) {
        switch ($errorCode) {
            case UPLOAD_ERR_INI_SIZE:
                return 'File exceeds the upload_max_filesize directive in php.ini';
            case UPLOAD_ERR_FORM_SIZE:
                return 'File exceeds the MAX_FILE_SIZE directive in the HTML form';
            case UPLOAD_ERR_PARTIAL:
                return 'File was only partially uploaded';
            case UPLOAD_ERR_NO_FILE:
                return 'No file was uploaded';
            case UPLOAD_ERR_NO_TMP_DIR:
                return 'Missing temporary folder';
            case UPLOAD_ERR_CANT_WRITE:
                return 'Failed to write file to disk';
            case UPLOAD_ERR_EXTENSION:
                return 'File upload stopped by extension';
            default:
                return 'Unknown upload error';
        }
    }

    /**
     * Format bytes to human readable format
     * @param int $bytes Number of bytes
     * @return string Formatted string
     */
    private function formatBytes($bytes) {
        $units = ['B', 'KB', 'MB', 'GB'];
        $bytes = max($bytes, 0);
        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
        $pow = min($pow, count($units) - 1);

        $bytes /= pow(1024, $pow);

        return round($bytes, 2) . ' ' . $units[$pow];
    }

    /**
     * Get upload directory path
     * @return string Upload directory path
     */
    public function getUploadDir() {
        return $this->uploadDir;
    }

    /**
     * Get maximum file size
     * @return int Maximum file size in bytes
     */
    public function getMaxFileSize() {
        return $this->maxFileSize;
    }

    /**
     * Get allowed file types
     * @return array Array of allowed MIME types
     */
    public function getAllowedTypes() {
        return $this->allowedTypes;
    }

    /**
     * Check if storage limit would be exceeded
     * @param int $additionalSize Size of new files to be added
     * @return array Result with can_upload flag and current usage info
     */
    public function checkStorageLimit($additionalSize = 0) {
        try {
            $maxTotalSize = $this->dataManager->getConfig('storage_max_total_size');
            if (!$maxTotalSize) {
                return [
                    'can_upload' => true,
                    'unlimited' => true
                ];
            }

            $currentUsage = $this->calculateStorageUsage();
            $projectedUsage = $currentUsage + $additionalSize;

            return [
                'can_upload' => $projectedUsage <= $maxTotalSize,
                'current_usage' => $currentUsage,
                'max_size' => $maxTotalSize,
                'projected_usage' => $projectedUsage,
                'available_space' => max(0, $maxTotalSize - $currentUsage),
                'usage_percentage' => round(($currentUsage / $maxTotalSize) * 100, 2)
            ];

        } catch (Exception $e) {
            return [
                'can_upload' => true,
                'error' => $e->getMessage()
            ];
        }
    }

    /**
     * Calculate current storage usage
     * @return int Total storage usage in bytes
     */
    private function calculateStorageUsage() {
        $totalSize = 0;

        if (!is_dir($this->uploadDir)) {
            return 0;
        }

        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($this->uploadDir, RecursiveDirectoryIterator::SKIP_DOTS)
        );

        foreach ($iterator as $file) {
            if ($file->isFile()) {
                $totalSize += $file->getSize();
            }
        }

        return $totalSize;
    }
}

?>