Уявіть: користувач інтернет-магазину намагається завантажити 50 фотографій товару, браузер зависає, файли відправляються по одному, а прогресу немає. Клієнт іде до конкурентів. Ми вирішуємо цю проблему мультизавантаженням з паралельною відправкою та наочним прогресом. Така система називається bulk upload — масове завантаження файлів на сайт. Наші інженери 10+ років проектують такі системи — від простих форм до високонавантажених медіатек.
Чому це складно?
Неправильна реалізація призводить до тайм-аутів, дублікатів та втрати даних. Основні проблеми: відсутність валідації на клієнті (користувач завантажує 2 ГБ відео), N+1 запитів до сервера, блокування UI при завантаженні, вразливості (завантаження PHP-шеллів). Ми використовуємо перевірені патерни: чергу з обмеженням паралельних запитів (concurrency 3), повторні спроби при помилках та валідацію на двох рівнях.
Клієнтська та серверна валідація
На клієнті перевіряємо розмір і MIME-тип через атрибути accept і max, але це лише перша лінія. Серверна валідація обов'язкова: перевірка MIME за вмістом, сканування на шкідливий код, ліміт кількості файлів. В одному проекті ми виявили, що сторонній компонент завантаження React пропускав файли з подвійним розширенням — після впровадження серверної валідації атаки припинилися.
Як влаштований компонент завантаження на React?
В основі — черга файлів з власним статусом кожного. Атрибут multiple в input[type=file] дозволяє вибрати кілька файлів; фільтр по accept — перша лінія, але не захист. Клієнтська валідація перевіряє розмір (макс. 10 МБ) та кількість. Паралельне завантаження з обмеженням: три файли одночасно, інші чекають. Прогрес кожного файлу відображається через XMLHttpRequest. Ось приклад компонента:
// components/BulkUpload.tsx
import { useRef, useState, useCallback } from 'react'
interface UploadFile {
id: string
file: File
status: 'pending' | 'uploading' | 'done' | 'error'
progress: number
error?: string
url?: string // URL after upload
}
interface BulkUploadProps {
accept?: string
maxFiles?: number
maxSizeBytes?: number
onUploadComplete?: (urls: string[]) => void
}
export function BulkUpload({
accept = 'image/*',
maxFiles = 10,
maxSizeBytes = 10 * 1024 * 1024, // 10 MB
onUploadComplete,
}: BulkUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [files, setFiles] = useState<UploadFile[]>([])
const addFiles = useCallback((incoming: FileList | File[]) => {
const arr = Array.from(incoming)
const validated = arr
.filter(f => {
if (f.size > maxSizeBytes) {
alert(`${f.name}: file too large (max ${maxSizeBytes / 1024 / 1024} MB)`)
return false
}
return true
})
.slice(0, maxFiles - files.length)
setFiles(prev => [
...prev,
...validated.map(file => ({
id: crypto.randomUUID(),
file,
status: 'pending' as const,
progress: 0,
})),
])
}, [files.length, maxFiles, maxSizeBytes])
const uploadFile = useCallback(async (uploadFile: UploadFile) => {
const formData = new FormData()
formData.append('file', uploadFile.file)
setFiles(prev => prev.map(f =>
f.id === uploadFile.id ? { ...f, status: 'uploading' } : f
))
try {
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/upload')
xhr.setRequestHeader('X-CSRF-TOKEN', document.querySelector<HTMLMetaElement>('meta[name=csrf-token]')?.content ?? '')
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const progress = Math.round((e.loaded / e.total) * 100)
setFiles(prev => prev.map(f =>
f.id === uploadFile.id ? { ...f, progress } : f
))
}
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
const { url } = JSON.parse(xhr.responseText)
setFiles(prev => prev.map(f =>
f.id === uploadFile.id ? { ...f, status: 'done', progress: 100, url } : f
))
resolve()
} else {
reject(new Error(`HTTP ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('Network error'))
xhr.send(formData)
})
} catch (e) {
setFiles(prev => prev.map(f =>
f.id === uploadFile.id
? { ...f, status: 'error', error: (e as Error).message }
: f
))
}
}, [])
const uploadAll = useCallback(async () => {
const pending = files.filter(f => f.status === 'pending')
// Parallel, but max 3 at a time
const CONCURRENCY = 3
for (let i = 0; i < pending.length; i += CONCURRENCY) {
await Promise.all(pending.slice(i, i + CONCURRENCY).map(uploadFile))
}
const urls = files.filter(f => f.status === 'done' && f.url).map(f => f.url!)
onUploadComplete?.(urls)
}, [files, uploadFile, onUploadComplete])
const removeFile = (id: string) => {
setFiles(prev => prev.filter(f => f.id !== id))
}
return (
<div>
<button onClick={() => inputRef.current?.click()}>
Choose files
</button>
<input
ref={inputRef}
type="file"
multiple
accept={accept}
className="hidden"
onChange={e => e.target.files && addFiles(e.target.files)}
/>
{files.length > 0 && (
<ul className="mt-4 space-y-2">
{files.map(f => (
<li key={f.id} className="flex items-center gap-3">
<span className="truncate flex-1">{f.file.name}</span>
<span className="text-sm text-muted">
{(f.file.size / 1024).toFixed(1)} KB
</span>
{f.status === 'uploading' && (
<div className="w-24 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${f.progress}%` }}
/>
</div>
)}
{f.status === 'done' && <span className="text-green-600">✓</span>}
{f.status === 'error' && (
<span className="text-red-600 text-sm">{f.error}</span>
)}
<button
onClick={() => removeFile(f.id)}
disabled={f.status === 'uploading'}
aria-label={`Remove ${f.file.name}`}
>
✕
</button>
</li>
))}
</ul>
)}
{files.some(f => f.status === 'pending') && (
<button onClick={uploadAll} className="mt-4 btn-primary">
Upload {files.filter(f => f.status === 'pending').length} files
</button>
)}
</div>
)
}
Чому presigned URL в 5 разів швидше?
При завантаженні через сервер (multipart) кожен файл проходить через PHP/Laravel — при 50 файлах по 10 МБ сервер перетворюється на вузьке місце. Presigned URL дозволяє клієнту напряму завантажувати файли в S3, минаючи сервер. Це знижує навантаження і прискорює завантаження до 5 разів.
| Характеристика | Multipart через сервер | Presigned URL (пряме завантаження) |
|---|---|---|
| Навантаження на сервер | Високе (100% трафіку) | Мінімальне (тільки генерація URL) |
| Пропускна здатність | Обмежена сервером | Обмежена лише каналом клієнта |
| Завантаження 50 файлів по 10 МБ | ~2 хвилини | ~25 секунд |
| Безпека | Потрібна валідація на сервері | Валідація на клієнті + сервер |
Як реалізувати presigned URL на бекенді?
- Клієнт запитує presigned URL, передаючи ім'я файлу, MIME-тип і розмір.
- Сервер генерує URL з обмеженням за часом (15 хвилин) і повертає його.
- Клієнт завантажує файл напряму в S3 через PUT-запит.
- Після завантаження можна виконати постобробку (наприклад, конвертацію в WebP).
Генерація presigned URL — пара рядків на Laravel:
// Генерація presigned URL
public function presign(Request $request): JsonResponse
{
$request->validate([
'filename' => 'required|string|max:255',
'mime_type' => 'required|string|in:image/jpeg,image/png,image/webp,application/pdf',
'size' => 'required|integer|max:' . 10 * 1024 * 1024,
]);
$ext = pathinfo($request->filename, PATHINFO_EXTENSION);
$key = 'uploads/' . date('Y/m') . '/' . Str::uuid() . '.' . $ext;
$client = Storage::disk('s3')->getClient();
$cmd = $client->getCommand('PutObject', [
'Bucket' => config('filesystems.disks.s3.bucket'),
'Key' => $key,
'ContentType' => $request->mime_type,
'ACL' => 'private',
]);
$presignedUrl = (string) $client->createPresignedRequest($cmd, '+15 minutes')->getUri();
return response()->json([
'upload_url' => $presignedUrl,
'public_url' => Storage::url($key),
'key' => $key,
]);
}
На фронтенді завантаження в S3 виглядає так:
// Фронтенд: завантаження напряму в S3
async function uploadToS3(file: File): Promise<string> {
// 1. Отримати presigned URL від нашого сервера
const { upload_url, public_url } = await fetch('/api/upload/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
mime_type: file.type,
size: file.size,
}),
}).then(r => r.json())
// 2. Завантажити файл напряму в S3
await fetch(upload_url, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
})
return public_url
}
Як налаштувати чергу з повторними спробами?
Для стійкості до мережевих помилок реалізуємо retry-логіку. Кожен файл може бути перезавантажений до 3 разів з експоненційною затримкою. Це економить час користувача і знижує кількість невдалих завантажень. Використовуємо Laravel Queues з налаштуваннями retry: до 3 спроб із затримкою 1, 5, 15 секунд.
Деталі реалізації retry-логіки
У компоненті React кожне невдале завантаження переводить файл у статус 'error'. Користувач може натиснути кнопку повторного завантаження. На серверній стороні можна налаштувати чергу завдань для постобробки – при збої завдання автоматично поміщається назад у чергу. Це дозволяє пережити тимчасові збої S3 або мережі.
Що включає реалізація під ключ?
- React-компонент з чергою, прогресом і підтримкою скасування.
- Серверний API на Laravel 11 з валідацією, обробкою зображень (WebP) та інтеграцією з S3.
- Автоматичне створення presigned URL для прямого завантаження.
- Обробку помилок з повторними спробами (retry до 3 разів).
- Документацію по API та компоненту.
- 10+ років досвіду та понад 50 проектів з інтеграції завантаження файлів гарантують стабільність.
Терміни реалізації
| Етап | Термін |
|---|---|
| React-компонент + черга + прогрес | 2 дні |
| Серверний endpoint на Laravel | 1 день |
| Інтеграція S3 presigned URL | 1 день |
| WebP-конвертація, throttle, retry | 1 день |
| Разом (під ключ) | 2–4 дні |
Зв'яжіться з нами для безкоштовної оцінки вашого проекту. Замовте реалізацію під ключ — отримайте стабільне мультизавантаження за 2–4 дні. Також ви можете отримати консультацію по вашому проекту.







