
Классическая схема «клиент — бэкенд — файловое хранилище» удобна, пока файлы малы и редки. Но как только пользователи начинают грузить видео, архивы или большие изображения, серверы приложений превращаются в дорогой прокси: держат длинные соединения, тратят память на буферы, сеть и CPU на шифрование, а пиковые часы раздувают автоскейлинг.
Прямые загрузки (клиент —> S3/MinIO/Blob Storage) через подписанные ссылки решают это:
Идея проста:
Когда нужен multipart‑upload: для больших файлов (сотни мегабайт и гигабайты) — загрузка частями с возможностью докачки.
Ниже — рабочие примеры c AWS SDK v3. Они легко адаптируются под MinIO (совместим по API S3) и другие облака с S3‑совместимым слоем.
// file: server.ts
import express from 'express';
import crypto from 'crypto';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Конфиг из окружения (пример значений):
// AWS_REGION=eu-central-1
// UPLOADS_BUCKET=my-company-uploads
// MAX_UPLOAD_SIZE=52428800 (50MB)
// TENANT_PREFIX=tenant- (для примера, обычно берётся из auth)
const REGION = process.env.AWS_REGION || 'eu-central-1';
const BUCKET = process.env.UPLOADS_BUCKET!;
const MAX_SIZE = Number(process.env.MAX_UPLOAD_SIZE || 50 * 1024 * 1024);
const s3 = new S3Client({ region: REGION });
const app = express();
app.use(express.json());
// Простая аутентификация-псевдо: в проде берём tenantId из JWT/сессии
function getTenantId(req: express.Request) {
// пример: req.user.tenantId
return 'tenant123';
}
app.post('/uploads/initiate-put', async (req, res) => {
try {
const { fileName, contentType, expectedSize } = req.body as {
fileName: string; contentType: string; expectedSize: number;
};
if (!fileName || !contentType || !expectedSize) return res.status(400).json({ error: 'invalid_input' });
if (expectedSize > MAX_SIZE) return res.status(413).json({ error: 'too_large' });
const tenantId = getTenantId(req);
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
const key = `uploads/${tenantId}/${crypto.randomUUID()}.${ext}`;
const cmd = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
// Шифрование на стороне сервера
ServerSideEncryption: 'AES256',
// Можно потребовать Content-MD5 для целостности
});
const url = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 }); // 5 минут
// Запоминаем ожидаемый размер и тип в БД (опущено) — пригодится для валидации после загрузки
res.json({
uploadUrl: url,
method: 'PUT',
key,
headers: { 'Content-Type': contentType },
maxSize: MAX_SIZE
});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'internal' });
}
});
// Эндпоинт подтверждения: клиент вызывает после 200 OK от S3
app.post('/uploads/complete', async (req, res) => {
try {
const { key, expectedSize } = req.body as { key: string; expectedSize: number };
if (!key || !expectedSize) return res.status(400).json({ error: 'invalid_input' });
const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));
const size = head.ContentLength ?? 0;
if (size !== expectedSize) {
// Политика на ваше усмотрение: удалить, пометить ошибкой или оставить в карантине
return res.status(400).json({ error: 'size_mismatch', actual: size });
}
// Здесь: помечаем файл verified в БД, пускаем пост-обработку (очередь)
res.json({ status: 'ok', key });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'internal' });
}
});
app.listen(3000, () => console.log('Upload server on :3000'));
Клиентская загрузка через fetch:
// Предполагаем, что уже получили от бэкенда uploadUrl, headers и key
async function uploadFile(file, uploadUrl, headers) {
const res = await fetch(uploadUrl, {
method: 'PUT',
headers,
body: file,
});
if (!res.ok) throw new Error('upload_failed');
}
POST‑политика позволяет задать content-length-range — это прямое ограничение размера на стороне S3.
// file: post-policy.ts
import crypto from 'crypto';
import { S3Client } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
const s3 = new S3Client({ region: process.env.AWS_REGION || 'eu-central-1' });
const BUCKET = process.env.UPLOADS_BUCKET!;
const MAX_SIZE = Number(process.env.MAX_UPLOAD_SIZE || 50 * 1024 * 1024);
export async function createPostPolicy(tenantId: string, fileName: string, contentType: string) {
const ext = fileName.includes('.') ? fileName.split('.').pop() : 'bin';
const key = `uploads/${tenantId}/${crypto.randomUUID()}.${ext}`;
const { url, fields } = await createPresignedPost(s3, {
Bucket: BUCKET,
Key: key,
Conditions: [
['content-length-range', 1, MAX_SIZE],
['starts-with', '$Content-Type', contentType.split('/')[0]],
],
Fields: {
'Content-Type': contentType,
},
Expires: 300, // секунд
});
return { url, fields, key, maxSize: MAX_SIZE };
}
Клиент для POST (через FormData):
async function uploadViaPost(url, fields, file) {
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v));
form.append('file', file);
const res = await fetch(url, { method: 'POST', body: form });
if (!res.ok) throw new Error('upload_failed');
}
Для файлов >100–200 МБ используйте multipart: делим файл на части (например, по 8–16 МБ), подписываем каждую часть.
// file: multipart.ts
import { S3Client, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import crypto from 'crypto';
const s3 = new S3Client({ region: process.env.AWS_REGION || 'eu-central-1' });
const BUCKET = process.env.UPLOADS_BUCKET!;
export async function initiateMultipart(tenantId: string, fileName: string, contentType: string) {
const key = `uploads/${tenantId}/${crypto.randomUUID()}-${fileName}`;
const resp = await s3.send(new CreateMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
ContentType: contentType,
ServerSideEncryption: 'AES256',
}));
return { uploadId: resp.UploadId!, key };
}
export async function signPart(key: string, uploadId: string, partNumber: number) {
const cmd = new UploadPartCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId, PartNumber: partNumber });
const url = await getSignedUrl(s3, cmd, { expiresIn: 60 * 10 });
return { url };
}
export async function completeMultipart(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
await s3.send(new CompleteMultipartUploadCommand({
Bucket: BUCKET,
Key: key,
UploadId: uploadId,
MultipartUpload: { Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber) },
}));
}
export async function abortMultipart(key: string, uploadId: string) {
await s3.send(new AbortMultipartUploadCommand({ Bucket: BUCKET, Key: key, UploadId: uploadId }));
}
На клиенте для multipart сохраняем ETag каждой части из ответа S3 (заголовок ETag), чтобы корректно завершить сессию и уметь докачивать.
Пример Lambda, которая по событию S3 считает SHA‑256 и ставит тег файла:
# file: lambda_sha256.py
import boto3
import hashlib
import urllib.parse
iam = boto3.client('s3')
s3 = boto3.client('s3')
def handler(event, context):
for rec in event.get('Records', []):
bucket = rec['s3']['bucket']['name']
key = urllib.parse.unquote_plus(rec['s3']['object']['key'])
h = hashlib.sha256()
# Потоково читаем объект
obj = s3.get_object(Bucket=bucket, Key=key)
for chunk in obj['Body'].iter_chunks(chunk_size=1024 * 1024):
if chunk:
h.update(chunk)
digest = h.hexdigest()
# Добавляем тег sha256
s3.put_object_tagging(
Bucket=bucket,
Key=key,
Tagging={
'TagSet': [
{'Key': 'sha256', 'Value': digest},
{'Key': 'status', 'Value': 'verified'}
]
}
)
return {'ok': True}
Для вирус‑сканирования часто используют ClamAV в Lambda с EFS/Layers. Если обнаружен вредоносный файл — перемещаем в карантинный префикс и помечаем в БД.
Чтобы браузер мог отправлять запросы непосредственно в хранилище, настройте CORS бакета.
Пример CORS для PUT/POST/GET:
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["GET", "PUT", "POST"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-amz-request-id"],
"MaxAgeSeconds": 3000
}
]
Пример минимальной IAM‑политики для роли бэкенда, которая подписывает URL только под конкретный бакет и префикс:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:AbortMultipartUpload",
"s3:CreateMultipartUpload",
"s3:CompleteMultipartUpload",
"s3:PutObjectTagging",
"s3:HeadObject",
"s3:GetObject"
],
"Resource": [
"arn:aws:s3:::my-company-uploads/uploads/*"
]
}
]
}
И политика бакета, запрещающая публичный доступ и требующая TLS:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "DenyInsecureTransport",
"Effect": "Deny",
"Principal": "*",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::my-company-uploads",
"arn:aws:s3:::my-company-uploads/*"
],
"Condition": { "Bool": { "aws:SecureTransport": "false" } }
}
]
}
Оценка на реальном порядке величин:
Плюс: пользователи видят более быстрый аплоад (особенно при multipart), меньше обрывов и повторов.
Итог: прямые загрузки через подписанные ссылки — это редкий случай, когда и быстрее для пользователя, и дешевле для компании. Настроили один раз — и забыли про «боль гигабайт через бэкенд», сосредоточившись на ценности: обработке контента и бизнес‑логике.