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