Загрузка файлов

Полное руководство по загрузке, обработке и управлению файлами в MNRFY

Введение

MNRFY предоставляет мощные инструменты для работы с загружаемыми файлами: изображения, документы, архивы и другие типы. Система поддерживает валидацию, обработку изображений, организацию файлов и защиту от вредоносных файлов.

Базовая загрузка файлов

Простая форма загрузки

<form method="POST" action="/" enctype="multipart/form-data">
    {{ csrf() }}
    
    <input type="hidden" name="__handler" value="upload-file">
    
    <div class="form-group">
        <label>Выберите файл</label>
        <input type="file" name="file" required>
        <small class="form-text text-muted">
            Максимальный размер: 10 МБ. Разрешены: JPG, PNG, PDF, DOC
        </small>
    </div>
    
    <div class="form-group">
        <label>Описание файла</label>
        <input type="text" name="description" maxlength="255">
    </div>
    
    <button type="submit" class="btn btn-primary">
        <i class="fas fa-upload"></i> Загрузить файл
    </button>
</form>

Базовый обработчик загрузки

<?php
// /src/handlers/upload-file.php

return function($request, $response, $context) {
    $userId = $_SESSION['user_id'] ?? null;
    
    if (!$userId) {
        return [
            'success' => false,
            'error' => 'Необходима авторизация'
        ];
    }
    
    $file = $request->file('file');
    
    if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
        return [
            'success' => false,
            'error' => 'Ошибка загрузки файла'
        ];
    }
    
    // Валидация типа файла
    $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf', 'application/msword'];
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($file['tmp_name']);
    
    if (!in_array($mimeType, $allowedTypes)) {
        return [
            'success' => false,
            'error' => 'Недопустимый тип файла'
        ];
    }
    
    // Валидация размера (10 МБ)
    if ($file['size'] > 10 * 1024 * 1024) {
        return [
            'success' => false,
            'error' => 'Файл слишком большой (максимум 10 МБ)'
        ];
    }
    
    // Генерируем уникальное имя файла
    $extension = pathinfo($file['name'], PATHINFO_EXTENSION);
    $filename = 'file_' . $userId . '_' . time() . '_' . uniqid() . '.' . $extension;
    
    $uploadDir = MNRFY_UPLOADS . '/documents/';
    if (!is_dir($uploadDir)) {
        mkdir($uploadDir, 0755, true);
    }
    
    $uploadPath = $uploadDir . $filename;
    
    if (move_uploaded_file($file['tmp_name'], $uploadPath)) {
        // Сохраняем информацию о файле в БД
        $db = $context->db('1752665380840');
        
        $fileId = $db->addItem('uploaded_files', [
            'user_id' => $userId,
            'original_name' => $file['name'],
            'filename' => $filename,
            'mime_type' => $mimeType,
            'size' => $file['size'],
            'path' => '/documents/' . $filename,
            'description' => $request->input('description', ''),
            'created_at' => time(),
            'ip_address' => $request->ip()
        ]);
        
        return [
            'success' => true,
            'message' => 'Файл успешно загружен',
            'data' => [
                'file_id' => $fileId,
                'filename' => $filename,
                'url' => '/mnrfy-uploads/documents/' . $filename
            ]
        ];
    }
    
    return [
        'success' => false,
        'error' => 'Не удалось сохранить файл'
    ];
};
?>

Загрузка изображений

Форма загрузки аватара

<form method="POST" action="/" enctype="multipart/form-data" class="avatar-upload-form">
    {{ csrf() }}
    
    <input type="hidden" name="__handler" value="upload-avatar">
    
    <div class="avatar-upload-container">
        <div class="current-avatar">
            {% if user.avatar %}
                <img src="{{ user.avatar }}" alt="Текущий аватар" class="avatar-preview">
            {% else %}
                <div class="no-avatar">
                    <i class="fas fa-user fa-3x"></i>
                    <p>Нет аватара</p>
                </div>
            {% endif %}
        </div>
        
        <div class="upload-controls">
            <input type="file" id="avatar-input" name="avatar" accept="image/*" 
                   onchange="previewImage(this)" style="display: none;">
            
            <label for="avatar-input" class="btn btn-primary">
                <i class="fas fa-camera"></i> Выбрать фото
            </label>
            
            <div class="upload-requirements">
                <h5>Требования к изображению:</h5>
                <ul>
                    <li>Максимальный размер: 5 МБ</li>
                    <li>Форматы: JPG, PNG, GIF, WebP</li>
                    <li>Минимальные размеры: 100x100 пикселей</li>
                    <li>Рекомендуемые размеры: 400x400 пикселей</li>
                </ul>
            </div>
        </div>
    </div>
    
    <div class="image-preview" id="imagePreview" style="display: none;">
        <h5>Предварительный просмотр:</h5>
        <img id="previewImg" style="max-width: 300px; max-height: 300px; border-radius: 8px;">
    </div>
    
    <div class="form-actions">
        <button type="submit" class="btn btn-success" disabled id="upload-btn">
            <i class="fas fa-upload"></i> Загрузить аватар
        </button>
        
        {% if user.avatar %}
            <button type="button" class="btn btn-danger" onclick="removeAvatar()">
                <i class="fas fa-trash"></i> Удалить аватар
            </button>
        {% endif %}
    </div>
</form>

<script>
function previewImage(input) {
    const file = input.files[0];
    const preview = document.getElementById('imagePreview');
    const previewImg = document.getElementById('previewImg');
    const uploadBtn = document.getElementById('upload-btn');
    
    if (file) {
        // Проверяем размер файла
        if (file.size > 5 * 1024 * 1024) {
            alert('Файл слишком большой! Максимальный размер: 5 МБ');
            input.value = '';
            return;
        }
        
        // Проверяем тип файла
        if (!['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(file.type)) {
            alert('Недопустимый тип файла! Разрешены: JPG, PNG, GIF, WebP');
            input.value = '';
            return;
        }
        
        const reader = new FileReader();
        reader.onload = function(e) {
            previewImg.src = e.target.result;
            preview.style.display = 'block';
            uploadBtn.disabled = false;
            
            // Проверяем размеры изображения
            previewImg.onload = function() {
                if (this.naturalWidth < 100 || this.naturalHeight < 100) {
                    alert('Изображение слишком маленькое! Минимальные размеры: 100x100 пикселей');
                    input.value = '';
                    preview.style.display = 'none';
                    uploadBtn.disabled = true;
                }
            };
        };
        reader.readAsDataURL(file);
    } else {
        preview.style.display = 'none';
        uploadBtn.disabled = true;
    }
}

function removeAvatar() {
    if (confirm('Вы уверены, что хотите удалить аватар?')) {
        fetch('/', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                '__handler': 'remove-avatar',
                '_token': document.querySelector('[name="_token"]').value
            })
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                location.reload();
            } else {
                alert(data.error || 'Ошибка при удалении аватара');
            }
        });
    }
}
</script>

Обработчик загрузки аватара

<?php
// /src/handlers/upload-avatar.php

return function($request, $response, $context) {
    $userId = $_SESSION['user_id'] ?? null;
    
    if (!$userId) {
        return ['success' => false, 'error' => 'Необходима авторизация'];
    }
    
    $file = $request->file('avatar');
    
    if (!$file || $file['error'] !== UPLOAD_ERR_OK) {
        return ['success' => false, 'error' => 'Ошибка загрузки файла'];
    }
    
    // Валидация типа файла
    $allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
    $finfo = new finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($file['tmp_name']);
    
    if (!in_array($mimeType, $allowedTypes)) {
        return ['success' => false, 'error' => 'Недопустимый тип файла'];
    }
    
    // Валидация размера файла (5MB)
    if ($file['size'] > 5 * 1024 * 1024) {
        return ['success' => false, 'error' => 'Файл слишком большой (максимум 5MB)'];
    }
    
    // Валидация размеров изображения
    $imageInfo = getimagesize($file['tmp_name']);
    if (!$imageInfo) {
        return ['success' => false, 'error' => 'Неверный формат изображения'];
    }
    
    [$width, $height] = $imageInfo;
    
    if ($width < 100 || $height < 100) {
        return ['success' => false, 'error' => 'Изображение слишком маленькое (минимум 100x100px)'];
    }
    
    if ($width > 4000 || $height > 4000) {
        return ['success' => false, 'error' => 'Изображение слишком большое (максимум 4000x4000px)'];
    }
    
    try {
        $db = $context->db('1752665380840');
        
        // Получаем текущего пользователя
        $user = $db->getItem('users', ['id' => $userId]);
        
        // Генерируем уникальное имя файла
        $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
        $filename = 'avatar_' . $userId . '_' . time() . '_' . uniqid() . '.' . $extension;
        
        $uploadDir = MNRFY_UPLOADS . '/avatars/';
        if (!is_dir($uploadDir)) {
            mkdir($uploadDir, 0755, true);
        }
        
        $uploadPath = $uploadDir . $filename;
        
        if (move_uploaded_file($file['tmp_name'], $uploadPath)) {
            // Создаем миниатюры разных размеров
            $thumbnails = $this->createThumbnails($uploadPath, $uploadDir, $filename, [
                'thumb' => [150, 150],
                'medium' => [300, 300],
                'large' => [600, 600]
            ]);
            
            // Удаляем старый аватар
            if ($user['avatar']) {
                $this->removeOldAvatar($user['avatar']);
            }
            
            // Обновляем в базе данных
            $db->editItem('users', ['id' => $userId], [
                'avatar' => '/avatars/' . $filename,
                'avatar_thumbnails' => json_encode($thumbnails),
                'updated_at' => time()
            ]);
            
            // Записываем в логи
            $db->addItem('user_logs', [
                'user_id' => $userId,
                'action' => 'avatar_updated',
                'details' => json_encode([
                    'filename' => $filename,
                    'original_name' => $file['name'],
                    'size' => $file['size'],
                    'dimensions' => $width . 'x' . $height
                ]),
                'ip_address' => $request->ip(),
                'created_at' => time()
            ]);
            
            return [
                'success' => true,
                'message' => 'Аватар успешно загружен',
                'data' => [
                    'avatar_url' => '/mnrfy-uploads/avatars/' . $filename,
                    'thumbnails' => $thumbnails
                ]
            ];
        }
        
        return ['success' => false, 'error' => 'Не удалось сохранить файл'];
        
    } catch (Exception $e) {
        error_log('Avatar upload error: ' . $e->getMessage());
        
        return [
            'success' => false,
            'error' => 'Произошла ошибка при загрузке аватара'
        ];
    }
};

// Функция создания миниатюр
function createThumbnails($sourcePath, $uploadDir, $filename, $sizes) {
    $thumbnails = [];
    $imageInfo = getimagesize($sourcePath);
    if (!$imageInfo) return $thumbnails;
    
    [$sourceWidth, $sourceHeight, $sourceType] = $imageInfo;
    
    // Создаем изображение из источника
    switch ($sourceType) {
        case IMAGETYPE_JPEG:
            $sourceImage = imagecreatefromjpeg($sourcePath);
            break;
        case IMAGETYPE_PNG:
            $sourceImage = imagecreatefrompng($sourcePath);
            break;
        case IMAGETYPE_GIF:
            $sourceImage = imagecreatefromgif($sourcePath);
            break;
        case IMAGETYPE_WEBP:
            $sourceImage = imagecreatefromwebp($sourcePath);
            break;
        default:
            return $thumbnails;
    }
    
    foreach ($sizes as $sizeName => [$thumbWidth, $thumbHeight]) {
        // Вычисляем размеры с сохранением пропорций
        $sourceAspectRatio = $sourceWidth / $sourceHeight;
        $thumbAspectRatio = $thumbWidth / $thumbHeight;
        
        if ($sourceAspectRatio > $thumbAspectRatio) {
            // Обрезаем по ширине
            $newHeight = $sourceHeight;
            $newWidth = $sourceHeight * $thumbAspectRatio;
            $cropX = ($sourceWidth - $newWidth) / 2;
            $cropY = 0;
        } else {
            // Обрезаем по высоте
            $newWidth = $sourceWidth;
            $newHeight = $sourceWidth / $thumbAspectRatio;
            $cropX = 0;
            $cropY = ($sourceHeight - $newHeight) / 2;
        }
        
        // Создаем миниатюру
        $thumbImage = imagecreatetruecolor($thumbWidth, $thumbHeight);
        
        // Сохраняем прозрачность для PNG
        if ($sourceType == IMAGETYPE_PNG) {
            imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127));
            imagealphablending($thumbImage, false);
            imagesavealpha($thumbImage, true);
        }
        
        imagecopyresampled(
            $thumbImage, $sourceImage,
            0, 0, $cropX, $cropY,
            $thumbWidth, $thumbHeight, $newWidth, $newHeight
        );
        
        // Сохраняем миниатюру
        $thumbFilename = $sizeName . '_' . $filename;
        $thumbPath = $uploadDir . $thumbFilename;
        
        switch ($sourceType) {
            case IMAGETYPE_JPEG:
                imagejpeg($thumbImage, $thumbPath, 85);
                break;
            case IMAGETYPE_PNG:
                imagepng($thumbImage, $thumbPath, 8);
                break;
            case IMAGETYPE_GIF:
                imagegif($thumbImage, $thumbPath);
                break;
            case IMAGETYPE_WEBP:
                imagewebp($thumbImage, $thumbPath, 85);
                break;
        }
        
        imagedestroy($thumbImage);
        
        $thumbnails[$sizeName] = '/avatars/' . $thumbFilename;
    }
    
    imagedestroy($sourceImage);
    
    return $thumbnails;
}

// Функция удаления старого аватара
function removeOldAvatar($avatarPath) {
    $fullPath = MNRFY_UPLOADS . ltrim($avatarPath, '/');
    
    if (file_exists($fullPath)) {
        unlink($fullPath);
        
        // Удаляем миниатюры
        $dir = dirname($fullPath);
        $filename = basename($fullPath);
        
        $thumbnailPrefixes = ['thumb_', 'medium_', 'large_'];
        foreach ($thumbnailPrefixes as $prefix) {
            $thumbPath = $dir . '/' . $prefix . $filename;
            if (file_exists($thumbPath)) {
                unlink($thumbPath);
            }
        }
    }
}
?>

Множественная загрузка файлов

Форма для загрузки нескольких файлов

<form method="POST" action="/" enctype="multipart/form-data" class="multiple-upload-form">
    {{ csrf() }}
    
    <input type="hidden" name="__handler" value="upload-gallery">
    
    <div class="upload-zone" id="uploadZone">
        <div class="upload-message">
            <i class="fas fa-cloud-upload-alt fa-3x"></i>
            <h4>Перетащите файлы сюда или нажмите для выбора</h4>
            <p>Можно загружать несколько изображений одновременно</p>
            <input type="file" id="fileInput" name="files[]" multiple accept="image/*" style="display: none;">
        </div>
    </div>
    
    <div id="filePreview" class="file-preview-container" style="display: none;">
        <h5>Выбранные файлы:</h5>
        <div id="previewList" class="preview-list"></div>
        
        <div class="upload-controls">
            <button type="button" class="btn btn-secondary" onclick="clearFiles()">
                <i class="fas fa-times"></i> Очистить
            </button>
            <button type="submit" class="btn btn-primary">
                <i class="fas fa-upload"></i> Загрузить файлы
            </button>
        </div>
    </div>
    
    <div class="progress" id="uploadProgress" style="display: none;">
        <div class="progress-bar" role="progressbar" style="width: 0%"></div>
    </div>
</form>

<script>
document.addEventListener('DOMContentLoaded', function() {
    const uploadZone = document.getElementById('uploadZone');
    const fileInput = document.getElementById('fileInput');
    const filePreview = document.getElementById('filePreview');
    const previewList = document.getElementById('previewList');
    
    // Обработка клика по зоне загрузки
    uploadZone.addEventListener('click', function() {
        fileInput.click();
    });
    
    // Обработка drag & drop
    uploadZone.addEventListener('dragover', function(e) {
        e.preventDefault();
        uploadZone.classList.add('dragover');
    });
    
    uploadZone.addEventListener('dragleave', function(e) {
        e.preventDefault();
        uploadZone.classList.remove('dragover');
    });
    
    uploadZone.addEventListener('drop', function(e) {
        e.preventDefault();
        uploadZone.classList.remove('dragover');
        
        const files = e.dataTransfer.files;
        handleFiles(files);
    });
    
    // Обработка выбора файлов
    fileInput.addEventListener('change', function(e) {
        handleFiles(e.target.files);
    });
    
    function handleFiles(files) {
        previewList.innerHTML = '';
        filePreview.style.display = 'block';
        
        Array.from(files).forEach((file, index) => {
            if (!file.type.startsWith('image/')) {
                return;
            }
            
            const reader = new FileReader();
            reader.onload = function(e) {
                const previewItem = document.createElement('div');
                previewItem.className = 'preview-item';
                previewItem.innerHTML = `
                    <img src="{$e.target.result}" alt="Preview" style="width: 120px; height: 120px; object-fit: cover;">
                    <div class="file-info">
                        <p class="file-name">{$file.name}</p>
                        <p class="file-size">{$(file.size / 1024 / 1024).toFixed(2)} MB</p>
                    </div>
                    <button type="button" class="btn btn-sm btn-danger remove-file" onclick="removeFile(this, {$index})">
                        <i class="fas fa-times"></i>
                    </button>
                `;
                previewList.appendChild(previewItem);
            };
            reader.readAsDataURL(file);
        });
    }
    
    // AJAX загрузка с прогресс-баром
    document.querySelector('.multiple-upload-form').addEventListener('submit', function(e) {
        e.preventDefault();
        
        const formData = new FormData(this);
        const progressBar = document.querySelector('#uploadProgress .progress-bar');
        const uploadProgress = document.getElementById('uploadProgress');
        
        uploadProgress.style.display = 'block';
        
        const xhr = new XMLHttpRequest();
        
        xhr.upload.addEventListener('progress', function(e) {
            if (e.lengthComputable) {
                const percentComplete = (e.loaded / e.total) * 100;
                progressBar.style.width = percentComplete + '%';
                progressBar.textContent = Math.round(percentComplete) + '%';
            }
        });
        
        xhr.addEventListener('load', function() {
            if (xhr.status === 200) {
                const response = JSON.parse(xhr.responseText);
                if (response.success) {
                    alert('Файлы успешно загружены!');
                    location.reload();
                } else {
                    alert(response.error || 'Ошибка при загрузке файлов');
                }
            } else {
                alert('Ошибка сервера');
            }
            
            uploadProgress.style.display = 'none';
        });
        
        xhr.open('POST', '/');
        xhr.send(formData);
    });
});

function removeFile(button, index) {
    button.closest('.preview-item').remove();
    
    // Удаляем файл из input (это сложно, проще пересоздать список)
    const fileInput = document.getElementById('fileInput');
    const dt = new DataTransfer();
    
    Array.from(fileInput.files).forEach((file, i) => {
        if (i !== index) {
            dt.items.add(file);
        }
    });
    
    fileInput.files = dt.files;
    
    if (fileInput.files.length === 0) {
        clearFiles();
    }
}

function clearFiles() {
    document.getElementById('fileInput').value = '';
    document.getElementById('filePreview').style.display = 'none';
    document.getElementById('previewList').innerHTML = '';
}
</script>

Обработчик множественной загрузки

<?php
// /src/handlers/upload-gallery.php

return function($request, $response, $context) {
    $userId = $_SESSION['user_id'] ?? null;
    
    if (!$userId) {
        return ['success' => false, 'error' => 'Необходима авторизация'];
    }
    
    $files = $request->file('files');
    
    if (empty($files)) {
        return ['success' => false, 'error' => 'Файлы не выбраны'];
    }
    
    $db = $context->db('1752665380840');
    $uploadedFiles = [];
    $errors = [];
    
    $uploadDir = MNRFY_UPLOADS . '/gallery/';
    if (!is_dir($uploadDir)) {
        mkdir($uploadDir, 0755, true);
    }
    
    foreach ($files as $index => $file) {
        if ($file['error'] !== UPLOAD_ERR_OK) {
            $errors[] = "Файл {$index}: ошибка загрузки";
            continue;
        }
        
        // Валидация
        $finfo = new finfo(FILEINFO_MIME_TYPE);
        $mimeType = $finfo->file($file['tmp_name']);
        
        if (!in_array($mimeType, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'])) {
            $errors[] = "Файл {$file['name']}: недопустимый тип";
            continue;
        }
        
        if ($file['size'] > 10 * 1024 * 1024) {
            $errors[] = "Файл {$file['name']}: слишком большой размер";
            continue;
        }
        
        $imageInfo = getimagesize($file['tmp_name']);
        if (!$imageInfo) {
            $errors[] = "Файл {$file['name']}: неверный формат изображения";
            continue;
        }
        
        [$width, $height] = $imageInfo;
        
        if ($width < 200 || $height < 200) {
            $errors[] = "Файл {$file['name']}: слишком маленькое изображение";
            continue;
        }
        
        // Загружаем файл
        try {
            $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
            $filename = 'gallery_' . $userId . '_' . time() . '_' . uniqid() . '.' . $extension;
            $uploadPath = $uploadDir . $filename;
            
            if (move_uploaded_file($file['tmp_name'], $uploadPath)) {
                // Создаем миниатюру
                $thumbnailPath = $this->createGalleryThumbnail($uploadPath, $uploadDir, $filename, 300, 300);
                
                // Оптимизируем оригинальное изображение
                $this->optimizeImage($uploadPath, $mimeType, $width, $height);
                
                // Сохраняем в БД
                $fileId = $db->addItem('gallery_images', [
                    'user_id' => $userId,
                    'filename' => $filename,
                    'original_name' => $file['name'],
                    'mime_type' => $mimeType,
                    'size' => $file['size'],
                    'width' => $width,
                    'height' => $height,
                    'path' => '/gallery/' . $filename,
                    'thumbnail_path' => $thumbnailPath,
                    'status' => 'active',
                    'created_at' => time(),
                    'ip_address' => $request->ip()
                ]);
                
                $uploadedFiles[] = [
                    'id' => $fileId,
                    'filename' => $filename,
                    'url' => '/mnrfy-uploads/gallery/' . $filename,
                    'thumbnail_url' => '/mnrfy-uploads' . $thumbnailPath
                ];
                
            } else {
                $errors[] = "Файл {$file['name']}: не удалось сохранить";
            }
            
        } catch (Exception $e) {
            $errors[] = "Файл {$file['name']}: ошибка обработки";
            error_log("Gallery upload error: " . $e->getMessage());
        }
    }
    
    $result = [
        'success' => !empty($uploadedFiles),
        'uploaded_count' => count($uploadedFiles),
        'uploaded_files' => $uploadedFiles
    ];
    
    if (!empty($errors)) {
        $result['errors'] = $errors;
        $result['error_count'] = count($errors);
    }
    
    if (!empty($uploadedFiles)) {
        $result['message'] = "Успешно загружено {count($uploadedFiles)} файлов";
        if (!empty($errors)) {
            $result['message'] .= " ({count($errors)} ошибок)";
        }
    } else {
        $result['error'] = 'Не удалось загрузить ни одного файла';
    }
    
    return $result;
};

function createGalleryThumbnail($sourcePath, $uploadDir, $filename, $thumbWidth, $thumbHeight) {
    $imageInfo = getimagesize($sourcePath);
    if (!$imageInfo) return null;
    
    [$sourceWidth, $sourceHeight, $sourceType] = $imageInfo;
    
    // Создаем изображение из источника
    switch ($sourceType) {
        case IMAGETYPE_JPEG:
            $sourceImage = imagecreatefromjpeg($sourcePath);
            break;
        case IMAGETYPE_PNG:
            $sourceImage = imagecreatefrompng($sourcePath);
            break;
        case IMAGETYPE_GIF:
            $sourceImage = imagecreatefromgif($sourcePath);
            break;
        case IMAGETYPE_WEBP:
            $sourceImage = imagecreatefromwebp($sourcePath);
            break;
        default:
            return null;
    }
    
    // Вычисляем размеры
    $sourceAspectRatio = $sourceWidth / $sourceHeight;
    $thumbAspectRatio = $thumbWidth / $thumbHeight;
    
    if ($sourceAspectRatio > $thumbAspectRatio) {
        $newHeight = $thumbHeight;
        $newWidth = $thumbHeight * $sourceAspectRatio;
        $offsetX = ($newWidth - $thumbWidth) / 2;
        $offsetY = 0;
    } else {
        $newWidth = $thumbWidth;
        $newHeight = $thumbWidth / $sourceAspectRatio;
        $offsetX = 0;
        $offsetY = ($newHeight - $thumbHeight) / 2;
    }
    
    // Создаем миниатюру
    $thumbImage = imagecreatetruecolor($thumbWidth, $thumbHeight);
    $tempImage = imagecreatetruecolor($newWidth, $newHeight);
    
    // Масштабируем
    imagecopyresampled($tempImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $sourceWidth, $sourceHeight);
    
    // Обрезаем до нужного размера
    imagecopy($thumbImage, $tempImage, 0, 0, $offsetX, $offsetY, $thumbWidth, $thumbHeight);
    
    // Сохраняем
    $thumbFilename = 'thumb_' . $filename;
    $thumbPath = $uploadDir . $thumbFilename;
    
    imagejpeg($thumbImage, $thumbPath, 85);
    
    imagedestroy($sourceImage);
    imagedestroy($tempImage);
    imagedestroy($thumbImage);
    
    return '/gallery/' . $thumbFilename;
}

function optimizeImage($imagePath, $mimeType, $width, $height) {
    // Оптимизируем большие изображения
    if ($width > 1920 || $height > 1920) {
        $maxSize = 1920;
        
        if ($width > $height) {
            $newWidth = $maxSize;
            $newHeight = ($height * $maxSize) / $width;
        } else {
            $newHeight = $maxSize;
            $newWidth = ($width * $maxSize) / $height;
        }
        
        // Создаем оптимизированную версию
        switch ($mimeType) {
            case 'image/jpeg':
                $sourceImage = imagecreatefromjpeg($imagePath);
                $optimizedImage = imagecreatetruecolor($newWidth, $newHeight);
                imagecopyresampled($optimizedImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
                imagejpeg($optimizedImage, $imagePath, 85);
                imagedestroy($sourceImage);
                imagedestroy($optimizedImage);
                break;
            case 'image/png':
                $sourceImage = imagecreatefrompng($imagePath);
                $optimizedImage = imagecreatetruecolor($newWidth, $newHeight);
                imagealphablending($optimizedImage, false);
                imagesavealpha($optimizedImage, true);
                imagecopyresampled($optimizedImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
                imagepng($optimizedImage, $imagePath, 8);
                imagedestroy($sourceImage);
                imagedestroy($optimizedImage);
                break;
        }
    }
}
?>

Управление файлами

Галерея загруженных файлов

<!-- Получаем файлы пользователя -->
{% set userFiles = db('1752665380840')->getItems('gallery_images', {
    'user_id': auth_id(),
    'status': 'active'
}, ['*'], 'created_at', 'DESC') %}

<div class="file-gallery">
    <div class="gallery-header">
        <h3>Ваши файлы ({{ userFiles | length }})</h3>
        <button type="button" class="btn btn-primary" onclick="toggleUploadForm()">
            <i class="fas fa-plus"></i> Добавить файлы
        </button>
    </div>
    
    {% if userFiles | length > 0 %}
        <div class="gallery-grid">
            {% foreach userFiles as file %}
                <div class="gallery-item" data-file-id="{{ file.id }}">
                    <div class="image-container">
                        <img src="{{ '/mnrfy-uploads' ~ file.thumbnail_path }}" 
                             alt="{{ file.original_name }}"
                             onclick="openLightbox('{{ '/mnrfy-uploads' ~ file.path }}')">
                        
                        <div class="image-overlay">
                            <div class="image-actions">
                                <button type="button" class="btn btn-sm btn-light" 
                                        onclick="copyImageUrl('{{ '/mnrfy-uploads' ~ file.path }}')"
                                        title="Скопировать ссылку">
                                    <i class="fas fa-link"></i>
                                </button>
                                
                                <button type="button" class="btn btn-sm btn-light" 
                                        onclick="downloadImage('{{ '/mnrfy-uploads' ~ file.path }}', '{{ file.original_name }}')"
                                        title="Скачать">
                                    <i class="fas fa-download"></i>
                                </button>
                                
                                <button type="button" class="btn btn-sm btn-danger" 
                                        onclick="deleteImage({{ file.id }})"
                                        title="Удалить">
                                    <i class="fas fa-trash"></i>
                                </button>
                            </div>
                        </div>
                    </div>
                    
                    <div class="image-info">
                        <p class="image-name" title="{{ file.original_name }}">
                            {{ file.original_name | truncate(20) }}
                        </p>
                        <small class="image-meta">
                            {{ file.width }}x{{ file.height }} • 
                            {{ (file.size / 1024) | round(1) }} КБ • 
                            {{ file.created_at | date('d.m.Y') }}
                        </small>
                    </div>
                </div>
            {% endforeach %}
        </div>
    {% else %}
        <div class="empty-gallery">
            <i class="fas fa-images fa-4x"></i>
            <h4>У вас пока нет загруженных файлов</h4>
            <p>Нажмите кнопку "Добавить файлы" чтобы загрузить изображения</p>
        </div>
    {% endif %}
</div>

<!-- Lightbox для просмотра изображений -->
<div id="lightbox" class="lightbox" onclick="closeLightbox()">
    <div class="lightbox-content">
        <span class="lightbox-close" onclick="closeLightbox()">×</span>
        <img id="lightboxImage" src="">
    </div>
</div>

<script>
function copyImageUrl(url) {
    const fullUrl = window.location.origin + url;
    navigator.clipboard.writeText(fullUrl).then(() => {
        showNotification('Ссылка скопирована в буфер обмена', 'success');
    });
}

function downloadImage(url, filename) {
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
}

function deleteImage(fileId) {
    if (confirm('Вы уверены, что хотите удалить это изображение?')) {
        fetch('/', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                '__handler': 'delete-image',
                'file_id': fileId,
                '_token': document.querySelector('[name="_token"]').value
            })
        })
        .then(response => response.json())
        .then(data => {
            if (data.success) {
                document.querySelector(`[data-file-id="{$fileId}"]`).remove();
                showNotification('Изображение удалено', 'success');
            } else {
                showNotification(data.error || 'Ошибка при удалении', 'error');
            }
        });
    }
}

function openLightbox(imageUrl) {
    document.getElementById('lightboxImage').src = imageUrl;
    document.getElementById('lightbox').style.display = 'flex';
}

function closeLightbox() {
    document.getElementById('lightbox').style.display = 'none';
}

function showNotification(message, type) {
    const notification = document.createElement('div');
    notification.className = `notification notification-{$type}`;
    notification.textContent = message;
    document.body.appendChild(notification);
    
    setTimeout(() => notification.remove(), 3000);
}
</script>
Отлично! Вы изучили все возможности работы с формами в MNRFY. Теперь переходите к изучению компонентов и макетов.