
Классическая схема «клиент — бэкенд — S3/GCS» выглядит простой, но на практике у неё три проблемы:
Прямые загрузки (клиент — облачное хранилище напрямую) решают все три проблемы: сервер выдаёт короткоживущие права и получает уведомление об успехе, а файл идёт в обход бэкенда. Выигрыш по времени для крупных файлов — в разы, по инфраструктурным затратам — ощутимо.
Высокоуровневая схема:
Важная часть — разделение ответственности: бэкенд хранит бизнес‑логику и выдаёт минимально необходимые права; загрузку и масштабирование трафика берёт на себя S3/GCS.
{
"CORSRules": [
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["PUT", "POST", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-request-id"],
"MaxAgeSeconds": 3000
}
]
}
import { randomUUID } from 'node:crypto';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type { Request, Response } from 'express';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET!;
// POST /uploads/presign
export async function presignUpload(req: Request, res: Response) {
const { fileName, contentType, size } = req.body as { fileName: string; contentType: string; size: number };
// Бизнес‑проверки: тип, размер, квоты пользователя
if (!fileName || !contentType || !Number.isFinite(size)) {
return res.status(400).json({ error: 'Некорректные параметры' });
}
const MAX_SIZE = 5 * 1024 * 1024 * 1024; // 5 ГБ
if (size <= 0 || size > MAX_SIZE) {
return res.status(413).json({ error: 'Слишком большой файл для обычной загрузки' });
}
// Генерируем безопасное имя и ключ
const safeName = fileName.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 200);
const userId = (req as any).user.id as string; // из аутентификации
const key = `uploads/${userId}/${randomUUID()}-${safeName}`;
const putCmd = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
Metadata: {
uploader: userId,
},
});
const expiresIn = 60 * 10; // 10 минут
const url = await getSignedUrl(s3, putCmd, { expiresIn });
return res.json({ url, key, expiresIn });
}
// POST /uploads/finalize
export async function finalizeUpload(req: Request, res: Response) {
const { key, expectedSize, etag } = req.body as { key: string; expectedSize: number; etag?: string };
if (!key || !Number.isFinite(expectedSize)) {
return res.status(400).json({ error: 'Некорректные параметры' });
}
try {
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
const actualSize = Number(head.ContentLength ?? 0);
if (actualSize !== expectedSize) {
return res.status(400).json({ error: 'Размер файла не совпадает' });
}
// Дополнительно можно проверить content-type, префикс ключа, владельца из head.Metadata
// Помечаем файл как «ожидает проверки» в базе и передаём ключ доменной сущности
// await db.attachPendingFile({ userId, key, size: actualSize, etag: head.ETag });
return res.json({ status: 'pending_scan', key, size: actualSize, etag: head.ETag });
} catch (e: any) {
if (e?.$metadata?.httpStatusCode === 404) {
return res.status(404).json({ error: 'Файл не найден' });
}
throw e;
}
}
async function uploadFile(file) {
// 1) Запрашиваем предподписанный URL
const presignResp = await fetch('/uploads/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: file.name, contentType: file.type || 'application/octet-stream', size: file.size }),
}).then(r => r.json());
// 2) PUT напрямую в S3
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', presignResp.url);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
console.log('progress', percent);
}
};
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error('Upload failed')));
xhr.onerror = reject;
xhr.send(file);
});
// 3) Финализируем на бэкенде
const finalize = await fetch('/uploads/finalize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: presignResp.key, expectedSize: file.size }),
}).then(r => r.json());
return finalize; // { status: 'pending_scan' | 'ready', ... }
}
Эта схема проста, быстра и подходит большинству сценариев, если файл не превышает 5 ГБ. Для больших и «ломких» сетей переходим к многосоставной загрузке.
Идея: разбиваем файл на части (обычно 5–64 МБ), загружаем параллельно, при обрыве докидываем недостающие части и завершаем.
import {
S3Client,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.UPLOADS_BUCKET!;
// POST /uploads/multipart/init
export async function initMultipart(req, res) {
const { fileName, contentType, size } = req.body;
const userId = req.user.id;
if (size <= 0) return res.status(400).json({ error: 'Размер обязателен' });
const key = `uploads/${userId}/${randomUUID()}-${fileName.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 200)}`;
const cmd = new CreateMultipartUploadCommand({ Bucket: BUCKET, Key: key, ContentType: contentType, Metadata: { uploader: userId } });
const resp = await s3.send(cmd);
return res.json({ uploadId: resp.UploadId, key });
}
// POST /uploads/multipart/sign-parts
export async function signParts(req, res) {
const { key, uploadId, partNumbers } = req.body as { key: string; uploadId: string; partNumbers: number[] };
const expiresIn = 60 * 20; // 20 минут
const urls = await Promise.all(
partNumbers.map(async (partNumber) => {
const cmd = new UploadPartCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber });
const url = await getSignedUrl(s3, cmd, { expiresIn });
return { partNumber, url };
})
);
res.json({ urls, expiresIn });
}
// POST /uploads/multipart/complete
export async function completeMultipart(req, res) {
const { key, uploadId, parts } = req.body as { key: string; uploadId: string; parts: { ETag: string; PartNumber: number }[] };
const cmd = new CompleteMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
UploadId: uploadId,
MultipartUpload: { Parts: parts.sort((a,b) => a.PartNumber - b.PartNumber) },
});
const out = await s3.send(cmd);
// Финализируем как в обычной загрузке
return res.json({ status: 'pending_scan', key, etag: out.ETag });
}
// POST /uploads/multipart/abort (по требованию)
export async function abortMultipart(req, res) {
const { key, uploadId } = req.body;
await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId }));
res.json({ aborted: true });
}
async function multipartUpload(file) {
// 1) init
const init = await fetch('/uploads/multipart/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: file.name, contentType: file.type, size: file.size }),
}).then(r => r.json());
const PART_SIZE = 8 * 1024 * 1024; // 8 МБ
const totalParts = Math.ceil(file.size / PART_SIZE);
const partNumbers = Array.from({ length: totalParts }, (_, i) => i + 1);
// 2) Запрашиваем ссылки для первой порции частей (можно батчами)
const { urls } = await fetch('/uploads/multipart/sign-parts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: init.key, uploadId: init.uploadId, partNumbers }),
}).then(r => r.json());
// 3) Грузим параллельно
const concurrency = 4;
const results = [];
let index = 0;
async function worker() {
while (index < urls.length) {
const current = urls[index++];
const start = (current.partNumber - 1) * PART_SIZE;
const end = Math.min(start + PART_SIZE, file.size);
const blob = file.slice(start, end);
const resp = await fetch(current.url, { method: 'PUT', body: blob });
if (!resp.ok) throw new Error(`Part ${current.partNumber} failed`);
const etag = resp.headers.get('ETag');
results.push({ ETag: etag, PartNumber: current.partNumber });
}
}
await Promise.all(Array.from({ length: concurrency }, worker));
// 4) Завершаем
const complete = await fetch('/uploads/multipart/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: init.key, uploadId: init.uploadId, parts: results }),
}).then(r => r.json());
return complete;
}
Замечание: ETag у многосоставных объектов — не MD5 файла. Для контроля целостности используйте стороннюю проверку (см. ниже) или современные «чексуммы» в S3 (опционально, усложняет схему подписи).
Пример простого воркера на Node.js, который помечает файл как «готов» (без антивируса, для краткости):
import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3';
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const sqs = new SQSClient({ region: process.env.AWS_REGION });
async function processOnce() {
const msg = await sqs.send(new ReceiveMessageCommand({ QueueUrl: process.env.QUEUE_URL, MaxNumberOfMessages: 10, WaitTimeSeconds: 10 }));
if (!msg.Messages?.length) return;
for (const m of msg.Messages) {
const body = JSON.parse(m.Body);
const record = JSON.parse(body.Message || body.Records?.[0] ? JSON.stringify(body.Records[0]) : '{}');
const bucket = record.s3?.bucket?.name;
const key = decodeURIComponent(record.s3?.object?.key || '');
if (bucket && key) {
const head = await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }));
// TODO: антивирус, валидация
// await db.markFileReady(key, Number(head.ContentLength));
}
await sqs.send(new DeleteMessageCommand({ QueueUrl: process.env.QUEUE_URL, ReceiptHandle: m.ReceiptHandle }));
}
}
В проде лучше использовать нативные триггеры S3 -> Lambda без SQS, либо S3 -> EventBridge -> очередь/воркер, чтобы не терять события и лучше контролировать ретраи.
Грубая оценка экономии: если сегодня вы гоняете через бэкенд 2 ТБ/мес пользовательских загрузок, то прямые загрузки сэкономят до 2 ТБ исходящего трафика с серверов и соответствующее время CPU/IO. Для команд с сотнями гигабайт — это ощутимо.
Прямые загрузки в S3/GCS по предподписанным ссылкам — это простой способ ускорить продукт, снять нагрузку с бэкенда и сократить расходы. Ключ к безопасному внедрению — короткие и строго ограниченные права, финализация на бэкенде и автоматическая проверка загруженного файла. Начните с PUT для файлов до 5 ГБ, добавьте multipart для больших — и вы увидите эффект уже в первую неделю после релиза.