Загрузка файлов — то место, где приложение принимает данные, полностью контролируемые пользователем, и сохраняет их на диск. Отсюда простое правило, определяющее всю тему: любой загруженный файл считается враждебным, пока не доказано обратное.
Разберём работу с дисками, S3 и очередями — и параллельно то, что отличает рабочую загрузку от опасной.
Filesystem в Laravel
Laravel абстрагирует работу с файлами через Filesystem. Можно переключаться между локальным хранилищем и S3 без изменения кода.
// Один и тот же код работает везде
Storage::disk('local')->put('file.txt', 'content');
Storage::disk('s3')->put('file.txt', 'content');Диски: local — папка storage/app, public — storage/app/public с симлинком, s3 — Amazon S3 или совместимые (Minio, DigitalOcean Spaces).
Настройка S3
composer require league/flysystem-aws-s3-v3 "^3.0"# .env
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=your_key
AWS_SECRET_ACCESS_KEY=your_secret
AWS_DEFAULT_REGION=eu-central-1
AWS_BUCKET=your-bucket
AWS_URL=https://your-bucket.s3.eu-central-1.amazonaws.com// config/filesystems.php
'disks' => [
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'), // Для Minio/DO Spaces
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
],Загрузка файлов
Контроллер
// app/Http/Controllers/FileController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class FileController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'file' => 'required|file|max:10240', // 10MB
]);
$file = $request->file('file');
// Автоматическое имя (hash)
$path = $file->store('uploads', 'public');
// Своё имя
$path = $file->storeAs(
'uploads',
time() . '_' . $file->getClientOriginalName(),
'public'
);
return response()->json([
'path' => $path,
'url' => Storage::disk('public')->url($path),
]);
}
public function uploadToS3(Request $request)
{
$request->validate([
'file' => 'required|file|max:102400', // 100MB
]);
$file = $request->file('file');
$path = $file->store('uploads/' . date('Y/m'), 's3');
return response()->json([
'path' => $path,
'url' => Storage::disk('s3')->url($path),
]);
}
}Прямая загрузка в S3 (Presigned URL)
// Для больших файлов — загрузка напрямую в S3
public function getPresignedUrl(Request $request)
{
$request->validate([
'filename' => 'required|string',
'content_type' => 'required|string',
]);
$key = 'uploads/' . date('Y/m') . '/' . uniqid() . '_' . $request->filename;
$client = Storage::disk('s3')->getClient();
$command = $client->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $key,
'ContentType' => $request->content_type,
]);
$presignedUrl = $client->createPresignedRequest($command, '+30 minutes');
return response()->json([
'url' => (string) $presignedUrl->getUri(),
'key' => $key,
]);
}Три правила безопасной загрузки, которые нужно применять всегда.
Никогда не используйте оригинальное имя файла. getClientOriginalName() — строка от клиента, и в ней может быть что угодно, включая ../ и управляющие символы. Генерируйте имя сами (Str::uuid()), а исходное — если оно нужно для скачивания — храните отдельным полем в базе.
Проверяйте тип по содержимому, а не по расширению. Валидация mimes:jpg,png в Laravel опирается на реальный MIME-тип файла, и это правильно; а вот самописная проверка по getClientOriginalExtension() обходится переименованием. shell.php с расширением .jpg — классика.
Загруженные файлы не должны выполняться. Директория загрузок либо вне корня сайта (диск local вместо public), либо с запретом на выполнение скриптов в конфиге веб-сервера. Если файлы отдаются через контроллер, а не напрямую, эта проблема снимается сама.
Оптимизация изображений
Intervention Image
composer require intervention/image-laravel
php artisan vendor:publish --provider="Intervention\Image\Laravel\ServiceProvider"use Intervention\Image\Laravel\Facades\Image;
class ImageController extends Controller
{
public function upload(Request $request)
{
$request->validate([
'image' => 'required|image|max:5120',
]);
$file = $request->file('image');
// Оптимизация
$image = Image::read($file);
// Ресайз с сохранением пропорций
$image->scaleDown(width: 1200);
// Конвертация в WebP
$encoded = $image->toWebp(quality: 80);
// Сохранение
$filename = uniqid() . '.webp';
$path = 'images/' . date('Y/m') . '/' . $filename;
Storage::disk('public')->put($path, $encoded);
return response()->json([
'url' => Storage::disk('public')->url($path),
]);
}
public function uploadWithThumbnails(Request $request)
{
$request->validate([
'image' => 'required|image|max:10240',
]);
$file = $request->file('image');
$baseName = pathinfo($file->hashName(), PATHINFO_FILENAME);
$folder = 'images/' . date('Y/m');
$image = Image::read($file);
// Original (max 2000px)
$original = $image->scaleDown(width: 2000)->toWebp(90);
Storage::disk('s3')->put("{$folder}/{$baseName}_original.webp", $original);
// Large (1200px)
$large = $image->scaleDown(width: 1200)->toWebp(85);
Storage::disk('s3')->put("{$folder}/{$baseName}_large.webp", $large);
// Medium (600px)
$medium = $image->scaleDown(width: 600)->toWebp(80);
Storage::disk('s3')->put("{$folder}/{$baseName}_medium.webp", $medium);
// Thumbnail (300px, crop)
$thumb = $image->cover(300, 300)->toWebp(75);
Storage::disk('s3')->put("{$folder}/{$baseName}_thumb.webp", $thumb);
return response()->json([
'original' => Storage::disk('s3')->url("{$folder}/{$baseName}_original.webp"),
'large' => Storage::disk('s3')->url("{$folder}/{$baseName}_large.webp"),
'medium' => Storage::disk('s3')->url("{$folder}/{$baseName}_medium.webp"),
'thumb' => Storage::disk('s3')->url("{$folder}/{$baseName}_thumb.webp"),
]);
}
}Обработка в очереди
// app/Jobs/ProcessUploadedImage.php
namespace App\Jobs;
use App\Models\Image as ImageModel;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
class ProcessUploadedImage implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
public ImageModel $imageModel,
public string $tempPath
) {}
public function handle(): void
{
$file = Storage::disk('local')->get($this->tempPath);
$image = Image::read($file);
$folder = 'images/' . date('Y/m');
$variants = [
'original' => ['width' => 2000, 'quality' => 90],
'large' => ['width' => 1200, 'quality' => 85],
'medium' => ['width' => 600, 'quality' => 80],
'thumb' => ['width' => 300, 'quality' => 75, 'crop' => true],
];
$urls = [];
foreach ($variants as $name => $settings) {
$processed = clone $image;
if ($settings['crop'] ?? false) {
$processed->cover($settings['width'], $settings['width']);
} else {
$processed->scaleDown(width: $settings['width']);
}
$encoded = $processed->toWebp($settings['quality']);
$path = "{$folder}/{$this->imageModel->id}_{$name}.webp";
Storage::disk('s3')->put($path, $encoded);
$urls[$name] = Storage::disk('s3')->url($path);
}
// Обновляем модель
$this->imageModel->update([
'urls' => $urls,
'processed_at' => now(),
]);
// Удаляем временный файл
Storage::disk('local')->delete($this->tempPath);
}
public function failed(\Throwable $exception): void
{
$this->imageModel->update(['processing_error' => $exception->getMessage()]);
Storage::disk('local')->delete($this->tempPath);
}
}Spatie Media Library
Популярный пакет для управления файлами:
composer require spatie/laravel-medialibrary
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate// app/Models/Product.php
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Product extends Model implements HasMedia
{
use InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('images')
->useFallbackUrl('/images/placeholder.jpg');
$this->addMediaCollection('documents')
->singleFile(); // Только один файл
}
public function registerMediaConversions(Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(300)
->height(300)
->sharpen(10);
$this->addMediaConversion('large')
->width(1200)
->performOnCollections('images');
}
}// Использование
// Добавление файла
$product->addMedia($request->file('image'))
->toMediaCollection('images');
// Из URL
$product->addMediaFromUrl('https://example.com/image.jpg')
->toMediaCollection('images');
// Получение
$product->getFirstMediaUrl('images'); // Original
$product->getFirstMediaUrl('images', 'thumb'); // Thumbnail
$product->getMedia('images'); // Все изображенияБезопасность: Валидируйте типы файлов. Не доверяйте расширению — проверяйте MIME-тип. Сканируйте на вирусы критичные загрузки.
Итоги
| Задача | Решение |
|---|---|
| Простая загрузка | $file->store() |
| S3 | Настроить диск, то же API |
| Большие файлы | Presigned URLs |
| Оптимизация | Intervention Image |
| Thumbnails | Jobs + очереди |
| Полное решение | Spatie Media Library |
Чек-лист, по которому я проверяю загрузку файлов перед выкладкой.
Имя файла генерируется на сервере. Пользовательское — только в базе, для показа.
Тип проверяется по содержимому. Расширение ничего не значит.
Есть ограничение размера — и на двух уровнях. Валидация в Laravel даст понятную ошибку, но до неё файл уже будет принят PHP: настройки upload_max_filesize и post_max_size должны быть согласованы, иначе большой файл превращается в невнятную ошибку без сообщения.
Обработка изображений — в очереди. Изменение размера тяжёлого изображения занимает секунды и память; в веб-запросе это либо таймаут, либо исчерпанный memory_limit. Заодно проверьте, что задача переживает падение: файл сохранён, миниатюры генерируются потом, и их отсутствие не должно ломать отображение.
S3 для продакшена, если приложение работает больше чем на одном сервере. Не ради масштабирования само по себе, а потому что локальный диск на двух серверах означает, что половина запросов не найдёт файл.
Удаление файла при удалении записи. Осиротевшие файлы — тихая утечка дискового пространства, которую обнаруживают через год по счёту за хранилище.
Отдельно про библиотеки обработки изображений. Они работают с недоверенными данными и исторически являются источником уязвимостей — специально сформированное изображение может привести к выполнению кода в обработчике. Отсюда: держите библиотеки обновлёнными, обрабатывайте изображения в фоновом процессе с ограничениями по памяти и времени, и не давайте пользователю контролировать параметры обработки (размеры, формат) без валидации по белому списку.
Комментарии
Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.