Загрузка файлов в объектное хранилище через presigned URL
Продвинутое3 минРедакция Innovialabs
Коротко
Сервер выдает браузеру короткоживущую подписанную ссылку, и файл уходит прямо в хранилище, минуя бэкенд; ограничения размера и типа задаются в подписи (лучше через presigned POST), а CORS разрешает загрузку только с вашего домена.
Суть подхода
Классическая схема «браузер → ваш сервер → хранилище» заставляет бэкенд принимать каждый байт, держать соединение открытым и платить за двойной трафик. Presigned URL — это ссылка с криптографической подписью, которую сервер генерирует своими ключами доступа. Браузер получает ее и загружает файл напрямую в S3-совместимое хранилище (AWS S3, Cloudflare R2, Yandex Object Storage, MinIO и другие).
Ключи при этом никогда не попадают на клиент: подпись разрешает ровно одно действие с одним объектом и только до истечения срока.
Почему это лучше проксирования
| Через сервер | Presigned URL | |
|---|---|---|
| Нагрузка на бэкенд | Принимает и пересылает весь файл | Только выдает подпись |
| Лимиты на тело запроса | Упираетесь в настройки прокси и платформы | Работают лимиты хранилища |
| Serverless | Ограничения на размер запроса и время выполнения | Подходит без оговорок |
| Скорость для пользователя | Два перехода | Один, до ближайшей точки хранилища |
| Контроль | Полный, но дорогой | Через условия подписи и проверку после загрузки |
Проксирование оправдано, только если файл нужно обработать до сохранения (например, антивирус в потоке) или хранилище недоступно из интернета.
Как устроен поток
- Клиент сообщает серверу имя, тип и размер файла.
- Сервер проверяет права пользователя, сам генерирует ключ объекта (не доверяя имени файла) и подписывает ссылку.
- Браузер загружает файл по ссылке.
- Клиент сообщает серверу об успешной загрузке, сервер проверяет объект (
HeadObject: размер, тип) и только потом записывает его в базу.
Срок жизни и размер: presigned POST
Для presigned PUT задается срок жизни, но ограничить диапазон размера нельзя. Presigned POST позволяет вписать в политику условия, которые хранилище проверит само: диапазон размера, тип, префикс ключа. Пример на AWS SDK для JavaScript v3:
import { S3Client } from "@aws-sdk/client-s3";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
const s3 = new S3Client({ region: process.env.S3_REGION });
export async function createUpload(userId: string, contentType: string) {
const key = `uploads/${userId}/${crypto.randomUUID()}`;
return createPresignedPost(s3, {
Bucket: process.env.S3_BUCKET!,
Key: key,
Conditions: [
["content-length-range", 1, 10 * 1024 * 1024], // до 10 МБ
["eq", "$Content-Type", contentType],
],
Fields: { "Content-Type": contentType },
Expires: 300, // секунд
});
}
На клиенте все поля из fields добавляются в FormData, а файл — последним:
const { url, fields } = await fetch("/api/upload").then((r) => r.json());
const form = new FormData();
Object.entries(fields).forEach(([k, v]) => form.append(k, v as string));
form.append("file", file);
await fetch(url, { method: "POST", body: form });
Срок жизни делайте коротким: ссылка нужна только на время начала загрузки. Поддержка presigned POST различается у S3-совместимых провайдеров — проверьте документацию своего. Если POST недоступен, используйте PUT и проверяйте размер после загрузки, удаляя объекты сверх лимита.
Настройка CORS
Без CORS браузер заблокирует запрос к домену хранилища. Разрешите только свои домены и нужные методы:
[
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["POST", "PUT"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]
ETag нужен в ExposeHeaders, если вы используете multipart-загрузку: клиенту придется читать его из ответа на каждую часть.
Частые ошибки
- Публичный бакет. Загрузка через подпись не требует публичного доступа. Отдавайте файлы тоже через подписанные ссылки или CDN.
- Ключ из имени файла. Приводит к перезаписи чужих объектов и проблемам с символами. Генерируйте ключ на сервере.
AllowedOrigins: ["*"]в продакшене.- Доверие клиенту. Тип и размер из запроса — лишь заявка. Проверяйте объект после загрузки.
- Нет очистки. Брошенные загрузки копятся. Настройте lifecycle-правило для временного префикса и для незавершенных multipart-загрузок.