- Created complete documentation in docs/ directory - Added PROJECT_OVERVIEW.md with feature highlights and getting started guide - Added ARCHITECTURE.md with system design and technical details - Added SECURITY.md with comprehensive security implementation guide - Added DEVELOPMENT.md with development workflows and best practices - Added DEPLOYMENT.md with production deployment instructions - Added API.md with complete REST API documentation - Added CONTRIBUTING.md with contribution guidelines - Added CHANGELOG.md with version history and migration notes - Reorganized all documentation files into docs/ directory for better organization - Updated README.md with proper documentation links and quick navigation - Enhanced project structure with professional documentation standards
631 lines
23 KiB
PHP
631 lines
23 KiB
PHP
<?php
|
|
/*******************************************************************************************************************
|
|
| Software Name : EasyStream
|
|
| Software Description : High End YouTube Clone Script with Videos, Shorts, Streams, Images, Audio, Documents, Blogs
|
|
| Software Author : (c) Sami Ahmed
|
|
|*******************************************************************************************************************
|
|
|
|
|
|*******************************************************************************************************************
|
|
| This source file is subject to the EasyStream Proprietary License Agreement.
|
|
|
|
|
| By using this software, you acknowledge having read this Agreement and agree to be bound thereby.
|
|
|*******************************************************************************************************************
|
|
| Copyright (c) 2025 Sami Ahmed. All rights reserved.
|
|
|*******************************************************************************************************************/
|
|
|
|
defined('_ISVALID') or header('Location: /error');
|
|
|
|
/**
|
|
* VImageManager - Comprehensive Image Upload and Management System
|
|
*
|
|
* This class handles all aspects of image management for the branding system:
|
|
* - Image uploads with validation
|
|
* - Automatic resizing and optimization
|
|
* - Multiple format generation (WebP, AVIF)
|
|
* - Dimension recommendations
|
|
* - Watermarking
|
|
* - CDN integration
|
|
*/
|
|
class VImageManager
|
|
{
|
|
private static $instance = null;
|
|
private $uploadDir;
|
|
private $maxFileSize;
|
|
private $allowedTypes;
|
|
private $branding;
|
|
|
|
public static function getInstance()
|
|
{
|
|
if (self::$instance === null) {
|
|
self::$instance = new self();
|
|
}
|
|
return self::$instance;
|
|
}
|
|
|
|
private function __construct()
|
|
{
|
|
$this->branding = VBranding::getInstance();
|
|
$this->uploadDir = $this->branding->get('upload_directory', 'f_data/branding_images');
|
|
$this->maxFileSize = $this->branding->get('max_upload_size', 5242880); // 5MB
|
|
$this->allowedTypes = explode(',', $this->branding->get('allowed_image_types', 'jpg,jpeg,png,gif,svg,webp'));
|
|
|
|
// Ensure upload directory exists
|
|
$this->ensureUploadDirectory();
|
|
}
|
|
|
|
/**
|
|
* Get image dimension presets
|
|
*/
|
|
public function getImagePresets()
|
|
{
|
|
global $db;
|
|
$presets = [];
|
|
|
|
try {
|
|
$sql = "SELECT * FROM db_image_presets ORDER BY is_required DESC, preset_name";
|
|
$result = $db->Execute($sql);
|
|
|
|
if ($result) {
|
|
while (!$result->EOF) {
|
|
$presets[] = [
|
|
'id' => $result->fields['id'],
|
|
'name' => $result->fields['preset_name'],
|
|
'key' => $result->fields['preset_key'],
|
|
'width' => (int) $result->fields['width'],
|
|
'height' => (int) $result->fields['height'],
|
|
'aspect_ratio' => $result->fields['aspect_ratio'],
|
|
'description' => $result->fields['description'],
|
|
'use_case' => $result->fields['use_case'],
|
|
'is_required' => (bool) $result->fields['is_required']
|
|
];
|
|
$result->MoveNext();
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Failed to get image presets: ' . $e->getMessage());
|
|
}
|
|
|
|
return $presets;
|
|
}
|
|
|
|
/**
|
|
* Get preset by key
|
|
*/
|
|
public function getPreset($key)
|
|
{
|
|
global $db;
|
|
|
|
try {
|
|
$sql = "SELECT * FROM db_image_presets WHERE preset_key = ?";
|
|
$result = $db->Execute($sql, [$key]);
|
|
|
|
if ($result && !$result->EOF) {
|
|
return [
|
|
'id' => $result->fields['id'],
|
|
'name' => $result->fields['preset_name'],
|
|
'key' => $result->fields['preset_key'],
|
|
'width' => (int) $result->fields['width'],
|
|
'height' => (int) $result->fields['height'],
|
|
'aspect_ratio' => $result->fields['aspect_ratio'],
|
|
'description' => $result->fields['description'],
|
|
'use_case' => $result->fields['use_case'],
|
|
'is_required' => (bool) $result->fields['is_required']
|
|
];
|
|
}
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Failed to get preset: ' . $e->getMessage());
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Upload and process image
|
|
*/
|
|
public function uploadImage($file, $imageKey, $presetKey = null)
|
|
{
|
|
try {
|
|
// Validate file
|
|
$validation = $this->validateUpload($file, $presetKey);
|
|
if (!$validation['valid']) {
|
|
return ['success' => false, 'error' => $validation['error']];
|
|
}
|
|
|
|
// Get preset if specified
|
|
$preset = $presetKey ? $this->getPreset($presetKey) : null;
|
|
|
|
// Generate unique filename
|
|
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
$filename = $this->generateFilename($imageKey, $extension);
|
|
$filepath = $this->uploadDir . '/' . $filename;
|
|
|
|
// Move uploaded file
|
|
if (!move_uploaded_file($file['tmp_name'], $filepath)) {
|
|
return ['success' => false, 'error' => 'Failed to move uploaded file'];
|
|
}
|
|
|
|
// Get image dimensions
|
|
$imageInfo = getimagesize($filepath);
|
|
if (!$imageInfo) {
|
|
unlink($filepath);
|
|
return ['success' => false, 'error' => 'Invalid image file'];
|
|
}
|
|
|
|
$width = $imageInfo[0];
|
|
$height = $imageInfo[1];
|
|
$mimeType = $imageInfo['mime'];
|
|
|
|
// Store in database
|
|
$imageId = $this->storeImageRecord($imageKey, $file['name'], $filename, $filepath,
|
|
$file['size'], $mimeType, $width, $height, $preset);
|
|
|
|
if (!$imageId) {
|
|
unlink($filepath);
|
|
return ['success' => false, 'error' => 'Failed to store image record'];
|
|
}
|
|
|
|
// Process image (resize, optimize, generate variants)
|
|
$this->processImage($imageId, $filepath, $preset);
|
|
|
|
// Update branding setting
|
|
$this->branding->set($imageKey, '/' . $filepath, 'image');
|
|
|
|
return [
|
|
'success' => true,
|
|
'image_id' => $imageId,
|
|
'filename' => $filename,
|
|
'path' => '/' . $filepath,
|
|
'width' => $width,
|
|
'height' => $height,
|
|
'size' => $file['size']
|
|
];
|
|
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Image upload failed: ' . $e->getMessage());
|
|
return ['success' => false, 'error' => 'Upload failed: ' . $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate file upload
|
|
*/
|
|
private function validateUpload($file, $presetKey = null)
|
|
{
|
|
// Check for upload errors
|
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
|
return ['valid' => false, 'error' => $this->getUploadErrorMessage($file['error'])];
|
|
}
|
|
|
|
// Check file size
|
|
if ($file['size'] > $this->maxFileSize) {
|
|
$maxSizeMB = round($this->maxFileSize / 1024 / 1024, 1);
|
|
return ['valid' => false, 'error' => "File size exceeds maximum allowed size of {$maxSizeMB}MB"];
|
|
}
|
|
|
|
// Check file extension
|
|
$extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
|
|
if (!in_array($extension, $this->allowedTypes)) {
|
|
return ['valid' => false, 'error' => 'File type not allowed. Allowed types: ' . implode(', ', $this->allowedTypes)];
|
|
}
|
|
|
|
// Check if it's actually an image
|
|
$imageInfo = getimagesize($file['tmp_name']);
|
|
if (!$imageInfo) {
|
|
return ['valid' => false, 'error' => 'File is not a valid image'];
|
|
}
|
|
|
|
// Check dimensions against preset if specified
|
|
if ($presetKey) {
|
|
$preset = $this->getPreset($presetKey);
|
|
if ($preset) {
|
|
$width = $imageInfo[0];
|
|
$height = $imageInfo[1];
|
|
|
|
// Allow some tolerance for dimensions
|
|
$tolerance = 0.1; // 10% tolerance
|
|
$minWidth = $preset['width'] * (1 - $tolerance);
|
|
$maxWidth = $preset['width'] * (1 + $tolerance);
|
|
$minHeight = $preset['height'] * (1 - $tolerance);
|
|
$maxHeight = $preset['height'] * (1 + $tolerance);
|
|
|
|
if ($width < $minWidth || $width > $maxWidth || $height < $minHeight || $height > $maxHeight) {
|
|
return [
|
|
'valid' => false,
|
|
'error' => "Image dimensions ({$width}x{$height}) don't match recommended size ({$preset['width']}x{$preset['height']}). Auto-resize is " .
|
|
($this->branding->get('auto_resize_images', true) ? 'enabled' : 'disabled') . "."
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return ['valid' => true];
|
|
}
|
|
|
|
/**
|
|
* Store image record in database
|
|
*/
|
|
private function storeImageRecord($imageKey, $originalName, $filename, $filepath, $fileSize, $mimeType, $width, $height, $preset)
|
|
{
|
|
global $db;
|
|
|
|
try {
|
|
$recommendedWidth = $preset ? $preset['width'] : null;
|
|
$recommendedHeight = $preset ? $preset['height'] : null;
|
|
$imageType = $this->determineImageType($imageKey);
|
|
|
|
$sql = "INSERT INTO db_branding_images
|
|
(image_key, original_filename, stored_filename, file_path, file_size, mime_type,
|
|
width, height, recommended_width, recommended_height, image_type)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
original_filename = VALUES(original_filename),
|
|
stored_filename = VALUES(stored_filename),
|
|
file_path = VALUES(file_path),
|
|
file_size = VALUES(file_size),
|
|
mime_type = VALUES(mime_type),
|
|
width = VALUES(width),
|
|
height = VALUES(height),
|
|
updated_at = NOW()";
|
|
|
|
$result = $db->Execute($sql, [
|
|
$imageKey, $originalName, $filename, $filepath, $fileSize, $mimeType,
|
|
$width, $height, $recommendedWidth, $recommendedHeight, $imageType
|
|
]);
|
|
|
|
if ($result) {
|
|
return $db->Insert_ID();
|
|
}
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Failed to store image record: ' . $e->getMessage());
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Process image (resize, optimize, generate variants)
|
|
*/
|
|
private function processImage($imageId, $filepath, $preset)
|
|
{
|
|
try {
|
|
// Auto-resize if enabled and preset provided
|
|
if ($this->branding->get('auto_resize_images', true) && $preset) {
|
|
$this->resizeImage($filepath, $preset['width'], $preset['height']);
|
|
}
|
|
|
|
// Generate WebP variant if enabled
|
|
if ($this->branding->get('generate_webp', true)) {
|
|
$this->generateWebPVariant($imageId, $filepath);
|
|
}
|
|
|
|
// Generate retina variant if enabled
|
|
if ($this->branding->get('generate_retina', true) && $preset) {
|
|
$this->generateRetinaVariant($imageId, $filepath, $preset);
|
|
}
|
|
|
|
// Apply watermark if enabled
|
|
if ($this->branding->get('enable_watermark', false)) {
|
|
$this->applyWatermark($filepath);
|
|
}
|
|
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Image processing failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resize image to specified dimensions
|
|
*/
|
|
private function resizeImage($filepath, $targetWidth, $targetHeight)
|
|
{
|
|
$imageInfo = getimagesize($filepath);
|
|
if (!$imageInfo) return false;
|
|
|
|
$sourceWidth = $imageInfo[0];
|
|
$sourceHeight = $imageInfo[1];
|
|
$mimeType = $imageInfo['mime'];
|
|
|
|
// Don't resize if already correct size
|
|
if ($sourceWidth == $targetWidth && $sourceHeight == $targetHeight) {
|
|
return true;
|
|
}
|
|
|
|
// Create source image
|
|
switch ($mimeType) {
|
|
case 'image/jpeg':
|
|
$sourceImage = imagecreatefromjpeg($filepath);
|
|
break;
|
|
case 'image/png':
|
|
$sourceImage = imagecreatefrompng($filepath);
|
|
break;
|
|
case 'image/gif':
|
|
$sourceImage = imagecreatefromgif($filepath);
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
|
|
if (!$sourceImage) return false;
|
|
|
|
// Create target image
|
|
$targetImage = imagecreatetruecolor($targetWidth, $targetHeight);
|
|
|
|
// Preserve transparency for PNG and GIF
|
|
if ($mimeType == 'image/png' || $mimeType == 'image/gif') {
|
|
imagealphablending($targetImage, false);
|
|
imagesavealpha($targetImage, true);
|
|
$transparent = imagecolorallocatealpha($targetImage, 255, 255, 255, 127);
|
|
imagefilledrectangle($targetImage, 0, 0, $targetWidth, $targetHeight, $transparent);
|
|
}
|
|
|
|
// Resize image
|
|
imagecopyresampled($targetImage, $sourceImage, 0, 0, 0, 0,
|
|
$targetWidth, $targetHeight, $sourceWidth, $sourceHeight);
|
|
|
|
// Save resized image
|
|
$quality = $this->branding->get('image_quality', 85);
|
|
switch ($mimeType) {
|
|
case 'image/jpeg':
|
|
imagejpeg($targetImage, $filepath, $quality);
|
|
break;
|
|
case 'image/png':
|
|
imagepng($targetImage, $filepath, 9);
|
|
break;
|
|
case 'image/gif':
|
|
imagegif($targetImage, $filepath);
|
|
break;
|
|
}
|
|
|
|
// Clean up
|
|
imagedestroy($sourceImage);
|
|
imagedestroy($targetImage);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Generate WebP variant
|
|
*/
|
|
private function generateWebPVariant($imageId, $filepath)
|
|
{
|
|
if (!function_exists('imagewebp')) return false;
|
|
|
|
$imageInfo = getimagesize($filepath);
|
|
if (!$imageInfo) return false;
|
|
|
|
$mimeType = $imageInfo['mime'];
|
|
|
|
// Create source image
|
|
switch ($mimeType) {
|
|
case 'image/jpeg':
|
|
$sourceImage = imagecreatefromjpeg($filepath);
|
|
break;
|
|
case 'image/png':
|
|
$sourceImage = imagecreatefrompng($filepath);
|
|
break;
|
|
default:
|
|
return false;
|
|
}
|
|
|
|
if (!$sourceImage) return false;
|
|
|
|
// Generate WebP filename
|
|
$webpPath = preg_replace('/\.[^.]+$/', '.webp', $filepath);
|
|
|
|
// Save as WebP
|
|
$quality = $this->branding->get('webp_quality', 80);
|
|
$success = imagewebp($sourceImage, $webpPath, $quality);
|
|
|
|
if ($success) {
|
|
// Store variant record
|
|
$this->storeImageVariant($imageId, 'webp', $webpPath, $imageInfo[0], $imageInfo[1], 'image/webp');
|
|
}
|
|
|
|
imagedestroy($sourceImage);
|
|
return $success;
|
|
}
|
|
|
|
/**
|
|
* Generate retina (2x) variant
|
|
*/
|
|
private function generateRetinaVariant($imageId, $filepath, $preset)
|
|
{
|
|
$retinaWidth = $preset['width'] * 2;
|
|
$retinaHeight = $preset['height'] * 2;
|
|
|
|
// Generate retina filename
|
|
$pathInfo = pathinfo($filepath);
|
|
$retinaPath = $pathInfo['dirname'] . '/' . $pathInfo['filename'] . '@2x.' . $pathInfo['extension'];
|
|
|
|
// Copy original file
|
|
copy($filepath, $retinaPath);
|
|
|
|
// Resize to retina dimensions
|
|
if ($this->resizeImage($retinaPath, $retinaWidth, $retinaHeight)) {
|
|
$fileSize = filesize($retinaPath);
|
|
$this->storeImageVariant($imageId, 'retina', $retinaPath, $retinaWidth, $retinaHeight, 'image/jpeg', $fileSize);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Store image variant record
|
|
*/
|
|
private function storeImageVariant($parentId, $variantType, $filePath, $width, $height, $mimeType, $fileSize = null)
|
|
{
|
|
global $db;
|
|
|
|
if ($fileSize === null) {
|
|
$fileSize = filesize($filePath);
|
|
}
|
|
|
|
try {
|
|
$sql = "INSERT INTO db_branding_image_variants
|
|
(parent_image_id, variant_type, file_path, width, height, file_size, mime_type)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)";
|
|
|
|
return $db->Execute($sql, [$parentId, $variantType, $filePath, $width, $height, $fileSize, $mimeType]);
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Failed to store image variant: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get uploaded images
|
|
*/
|
|
public function getUploadedImages($imageType = null)
|
|
{
|
|
global $db;
|
|
$images = [];
|
|
|
|
try {
|
|
$sql = "SELECT * FROM db_branding_images WHERE is_active = 1";
|
|
$params = [];
|
|
|
|
if ($imageType) {
|
|
$sql .= " AND image_type = ?";
|
|
$params[] = $imageType;
|
|
}
|
|
|
|
$sql .= " ORDER BY upload_date DESC";
|
|
$result = $db->Execute($sql, $params);
|
|
|
|
if ($result) {
|
|
while (!$result->EOF) {
|
|
$images[] = [
|
|
'id' => $result->fields['id'],
|
|
'key' => $result->fields['image_key'],
|
|
'original_name' => $result->fields['original_filename'],
|
|
'filename' => $result->fields['stored_filename'],
|
|
'path' => '/' . $result->fields['file_path'],
|
|
'size' => $result->fields['file_size'],
|
|
'mime_type' => $result->fields['mime_type'],
|
|
'width' => $result->fields['width'],
|
|
'height' => $result->fields['height'],
|
|
'recommended_width' => $result->fields['recommended_width'],
|
|
'recommended_height' => $result->fields['recommended_height'],
|
|
'type' => $result->fields['image_type'],
|
|
'upload_date' => $result->fields['upload_date']
|
|
];
|
|
$result->MoveNext();
|
|
}
|
|
}
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Failed to get uploaded images: ' . $e->getMessage());
|
|
}
|
|
|
|
return $images;
|
|
}
|
|
|
|
/**
|
|
* Delete image and its variants
|
|
*/
|
|
public function deleteImage($imageId)
|
|
{
|
|
global $db;
|
|
|
|
try {
|
|
// Get image record
|
|
$sql = "SELECT * FROM db_branding_images WHERE id = ?";
|
|
$result = $db->Execute($sql, [$imageId]);
|
|
|
|
if (!$result || $result->EOF) {
|
|
return false;
|
|
}
|
|
|
|
$image = $result->fields;
|
|
|
|
// Delete physical file
|
|
if (file_exists($image['file_path'])) {
|
|
unlink($image['file_path']);
|
|
}
|
|
|
|
// Delete variants
|
|
$sql = "SELECT file_path FROM db_branding_image_variants WHERE parent_image_id = ?";
|
|
$variants = $db->Execute($sql, [$imageId]);
|
|
|
|
if ($variants) {
|
|
while (!$variants->EOF) {
|
|
if (file_exists($variants->fields['file_path'])) {
|
|
unlink($variants->fields['file_path']);
|
|
}
|
|
$variants->MoveNext();
|
|
}
|
|
}
|
|
|
|
// Delete database records (variants will be deleted by foreign key cascade)
|
|
$sql = "DELETE FROM db_branding_images WHERE id = ?";
|
|
return $db->Execute($sql, [$imageId]);
|
|
|
|
} catch (Exception $e) {
|
|
$logger = VLogger::getInstance();
|
|
$logger->logError('Failed to delete image: ' . $e->getMessage());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Helper methods
|
|
*/
|
|
private function ensureUploadDirectory()
|
|
{
|
|
if (!is_dir($this->uploadDir)) {
|
|
mkdir($this->uploadDir, 0755, true);
|
|
}
|
|
|
|
// Create .htaccess for security
|
|
$htaccessPath = $this->uploadDir . '/.htaccess';
|
|
if (!file_exists($htaccessPath)) {
|
|
file_put_contents($htaccessPath, "Options -Indexes\nOptions -ExecCGI\n");
|
|
}
|
|
}
|
|
|
|
private function generateFilename($imageKey, $extension)
|
|
{
|
|
$timestamp = time();
|
|
$random = substr(md5(uniqid()), 0, 8);
|
|
return "{$imageKey}_{$timestamp}_{$random}.{$extension}";
|
|
}
|
|
|
|
private function determineImageType($imageKey)
|
|
{
|
|
if (strpos($imageKey, 'logo') !== false) return 'logo';
|
|
if (strpos($imageKey, 'icon') !== false || strpos($imageKey, 'favicon') !== false) return 'icon';
|
|
if (strpos($imageKey, 'avatar') !== false) return 'avatar';
|
|
if (strpos($imageKey, 'thumbnail') !== false) return 'thumbnail';
|
|
if (strpos($imageKey, 'banner') !== false) return 'banner';
|
|
if (strpos($imageKey, 'bg_') !== false || strpos($imageKey, 'background') !== false) return 'background';
|
|
return 'logo';
|
|
}
|
|
|
|
private function getUploadErrorMessage($errorCode)
|
|
{
|
|
switch ($errorCode) {
|
|
case UPLOAD_ERR_INI_SIZE:
|
|
case UPLOAD_ERR_FORM_SIZE:
|
|
return 'File size exceeds maximum allowed size';
|
|
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';
|
|
}
|
|
}
|
|
} |