
Когда пользователи заливают большие файлы «через бэкенд», ваши сервера превращаются в дорогой прокси: двукратный трафик (клиент → бэкенд → хранилище), пиковая нагрузка на CPU/память, очереди и таймауты. Ошибки сети и разрывы на 92% случаев — и пользователь начинает заново.
Что даёт переход на прямые загрузки в облачное хранилище (например, S3‑совместимое):
Идеальная схема — бэкенд не переводит байты, а выдаёт короткоживущие подписи и оркестрирует процесс.
Клиент (браузер/моб) ── запрос на загрузку ─▶ Бэкенд (API)
│ генерирует подписи (коротко живут)
▼
Облачное хранилище (S3)
│ событие о новом объекте
▼
Очередь (SQS/пабсаб) → Сканер (антивирус) → Трансформеры (превью/извлечь мета)
│ │
└─────────── статус/вебхук/газетка в БД ◀────────┘
Ключевые принципы:
Пример браузерного кода: многосегментная загрузка в S3 с докачкой и контрольной суммой SHA‑256. Сервер выдаёт ID загрузки и подписанные URL по запросу на каждую часть.
// client/upload.js
// Минимальный пример: делим файл на части по 8 МБ, возобновляем с нужной части.
// Требования: бэкенд даёт API: /uploads/init, /uploads/part-url, /uploads/complete
const CHUNK_SIZE = 8 * 1024 * 1024;
async function sha256(file) {
const buf = await file.arrayBuffer();
const digest = await crypto.subtle.digest('SHA-256', buf);
return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
async function initUpload(fileName, contentType, size, checksum) {
const res = await fetch('/uploads/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, contentType, size, checksum })
});
if (!res.ok) throw new Error('init failed');
return res.json(); // { uploadId, key }
}
async function getPartUrl(uploadId, partNumber) {
const res = await fetch(`/uploads/part-url?uploadId=${encodeURIComponent(uploadId)}&partNumber=${partNumber}`);
if (!res.ok) throw new Error('part-url failed');
return res.json(); // { url }
}
async function completeUpload(uploadId, parts) {
const res = await fetch('/uploads/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, parts }) // parts: [{PartNumber, ETag}]
});
if (!res.ok) throw new Error('complete failed');
return res.json(); // { status: 'ok', key }
}
export async function uploadFile(file, onProgress) {
const checksumB64 = await sha256(file);
const { uploadId, key } = await initUpload(file.name, file.type || 'application/octet-stream', file.size, checksumB64);
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
const uploadedParts = [];
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
const start = (partNumber - 1) * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end);
const { url } = await getPartUrl(uploadId, partNumber);
const resp = await fetch(url, {
method: 'PUT',
body: blob,
headers: {
// Если включены контрольные суммы частей на S3:
// 'x-amz-checksum-sha256': await sha256(blob)
}
});
if (!resp.ok) throw new Error(`part ${partNumber} upload failed`);
const etag = resp.headers.get('ETag');
uploadedParts.push({ PartNumber: partNumber, ETag: etag?.replaceAll('"', '') });
onProgress?.({ uploaded: end, total: file.size, partNumber, totalParts });
}
const result = await completeUpload(uploadId, uploadedParts);
return { key: result.key };
}
Советы по UX:
Пример на Node.js (AWS SDK v3). Бэкенд хранит служебные данные загрузки (uploadId, key, ожидаемая сумма) в БД.
// server/uploads.js
import { S3Client, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import express from 'express';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOAD_BUCKET; // карантинный бакет/префикс
// Заглушка-хранилище для демо. В проде — БД.
const store = new Map();
const router = express.Router();
router.post('/uploads/init', async (req, res) => {
const { fileName, contentType, size, checksum } = req.body;
if (!fileName || !size) return res.status(400).json({ error: 'bad input' });
// Генерируем уникальный ключ (например, с пространством по арендаторам)
const tenant = req.headers['x-tenant'] || 'public';
const key = `${tenant}/incoming/${Date.now()}_${encodeURIComponent(fileName)}`;
// Инициируем многосегментную загрузку. Вешаем метаданные для сканера.
const cmd = new CreateMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType || 'application/octet-stream',
// В новых регионах можно включить встроенные контрольные суммы:
// ChecksumAlgorithm: 'SHA256',
Metadata: {
'expected-sha256': checksum || '',
'tenant': tenant,
'state': 'pending-scan'
},
ServerSideEncryption: 'AES256'
});
const out = await s3.send(cmd);
const uploadId = out.UploadId;
// Сохраняем сессию загрузки
store.set(uploadId, { key, expectedSha256: checksum, size, parts: [] });
res.json({ uploadId, key });
});
router.get('/uploads/part-url', async (req, res) => {
const { uploadId, partNumber } = req.query;
if (!store.has(uploadId)) return res.status(404).json({ error: 'not found' });
const { key } = store.get(uploadId);
const cmd = new UploadPartCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId, PartNumber: Number(partNumber) });
const url = await getSignedUrl(s3, cmd, { expiresIn: 60 * 10 }); // 10 минут
res.json({ url });
});
router.post('/uploads/complete', async (req, res) => {
const { uploadId, parts } = req.body;
const session = store.get(uploadId);
if (!session) return res.status(404).json({ error: 'not found' });
const cmd = new CompleteMultipartUploadCommand({
Bucket: BUCKET,
Key: session.key,
UploadId: uploadId,
MultipartUpload: { Parts: parts }
});
await s3.send(cmd);
// Записываем в БД статус «ожидает сканирования». Публикация — после скана.
store.delete(uploadId);
res.json({ status: 'ok', key: session.key });
});
export default router;
Минимальная IAM‑политика для роли бэкенда (ограничиваемся нужным префиксом):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:CreateMultipartUpload",
"s3:UploadPart",
"s3:AbortMultipartUpload",
"s3:CompleteMultipartUpload",
"s3:PutObjectTagging",
"s3:PutObject"
],
"Resource": [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]
}
]
}
Контроль целостности:
x-amz-checksum-sha256 на каждую часть (поддерживается не во всех регионах; проверяйте документацию провайдера).Файлы попадают в «карантин» (incoming). Событие о новом объекте идёт в очередь. Рабочий контейнер скачивает файл, прогоняет через антивирус (например, ClamAV), при успехе копирует в «безопасный» префикс и помечает в БД. При провале — помечает «infected» и удаляет или изолирует.
Пример обработчика на Python для AWS Lambda (подойдёт для файлов до нескольких сотен мегабайт при увеличенном tmp‑томе). Для больших — перенесите логику в ECS/Fargate.
# lambda_scan.py
import os
import json
import subprocess
import boto3
s3 = boto3.client('s3')
dynamodb = boto3.resource('dynamodb')
TABLE = os.environ.get('UPLOADS_TABLE') # для хранения статусов
SAFE_BUCKET = os.environ.get('SAFE_BUCKET')
# В Лямбде используйте сборку слоя с ClamAV или контейнер с включённым ClamAV.
def handler(event, context):
for record in event['Records']:
s3info = record['s3']
bucket = s3info['bucket']['name']
key = s3info['object']['key']
tmp_path = f"/tmp/{os.path.basename(key)}"
# Скачиваем файл в /tmp
s3.download_file(bucket, key, tmp_path)
# Запускаем clamscan
try:
res = subprocess.run(["clamscan", "--stdout", tmp_path], capture_output=True, text=True)
output = res.stdout
infected = "FOUND" in output
except Exception as e:
infected = True
output = str(e)
# Сверяем контрольную сумму, если есть
head = s3.head_object(Bucket=bucket, Key=key)
expected = head['Metadata'].get('expected-sha256')
if expected:
import hashlib, base64
h = hashlib.sha256()
with open(tmp_path, 'rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
h.update(chunk)
actual_b64 = base64.b64encode(h.digest()).decode('utf-8')
if actual_b64 != expected:
infected = True
output += "\nChecksum mismatch"
# Обновляем статус в БД
table = dynamodb.Table(TABLE)
item_key = { 'object_key': key }
if not infected:
# Копируем в безопасный бакет/префикс
dest_key = key.replace('/incoming/', '/safe/')
s3.copy_object(Bucket=SAFE_BUCKET, Key=dest_key, CopySource={'Bucket': bucket, 'Key': key}, MetadataDirective='REPLACE', Metadata={ 'scan': 'clean' })
# (по желанию) удаляем оригинал из карантина
s3.delete_object(Bucket=bucket, Key=key)
table.put_item(Item={ **item_key, 'status': 'clean', 'dest_key': dest_key })
else:
table.put_item(Item={ **item_key, 'status': 'infected', 'reason': output[-1000:] })
# (по политике) либо удаляем, либо оставляем для разбирательства
s3.delete_object(Bucket=bucket, Key=key)
return { 'statusCode': 200 }
Рекомендации по сканированию:
Трассировка: один correlation‑id от инициализации до публикации. Кладём его в:
Экономим:
Не экономим:
Итог: перевод загрузок на прямую схему с частями и короткоживущими подписями снимает львиную долю проблем со стабильностью и затратами. При правильной изоляции и антивирусном конвейере вы ускоряете онбординг крупных клиентов и снижаете риски безопасности — это даёт прямую выгоду бизнесу и спокойствие команде.