Загрузка фото¶
Фото грузится напрямую в хранилище (S3/MinIO) по presigned POST, минуя API. Так байты не проходят через наш сервер — это быстрее и масштабируемо, а лимит размера файла проверяет само хранилище ещё до записи.
Два шага¶
1) POST /v1/uploads ──▶ url + fields + upload_id (через API, с токеном)
2) POST <url> multipart-форма ──▶ хранилище принимает байты (напрямую, без токена)
Шаг 1. Запросить форму загрузки¶
curl -X POST "$BASE_URL/v1/uploads" \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"content_type":"image/jpeg"}'
Ответ — 201 Created:
{
"upload_id": "b3f1c2a4-…",
"url": "https://…/uploads",
"fields": {
"Content-Type": "image/jpeg",
"key": "<user-id>/<upload-id>.jpg",
"policy": "…base64…",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "…",
"x-amz-date": "20260101T000000Z",
"x-amz-signature": "…"
},
"expires_in": 600
}
content_type— MIME будущего файла (по умолчаниюimage/jpeg). Поддерживаютсяimage/jpeg,image/png,image/webp,image/heic; другой тип отклоняется сразу на этом шаге (422 validation_error).url+fields— одноразовая подписанная форма, живётexpires_inсекунд (~10 минут).upload_id— то, что потом передаётся в генерацию какupload_id.
Шаг 2. Залить файл формой¶
POST на url с телом multipart/form-data:
curl -X POST "<url>" \
-F 'Content-Type=image/jpeg' \
-F 'key=<из fields>' \
-F 'policy=<из fields>' \
-F 'x-amz-algorithm=<из fields>' \
-F 'x-amz-credential=<из fields>' \
-F 'x-amz-date=<из fields>' \
-F 'x-amz-signature=<из fields>' \
-F 'file=@photo.jpg'
Успех — 204 No Content от хранилища (не от нашего API), тела нет.
Важные правила¶
Поля формы — дословно, файл — последним
Каждую пару из fields нужно передать полем формы без изменений и без
фильтрации — набор полей может меняться, не хардкодь список, просто перекладывай
всё. Сам файл — полем с именем file, и оно обязано идти последней частью
формы: всё после файла хранилище игнорирует. Отдельный заголовок Content-Type
на файл выставлять не нужно — тип уже зашит в fields и подписан.
Ошибки хранилища — это XML, а не наш JSON-envelope
Ошибки шага 2 приходят от S3 в XML-формате:
- файл больше лимита →
400EntityTooLarge; - поля формы изменены/пропущены или форма истекла →
403AccessDenied/SignatureDoesNotMatch.
Не корми их парсеру нашего envelope — обрабатывай по HTTP-статусу.
- Лимит размера — до 15 МБ на файл; превышение отбивает само хранилище
(
400 EntityTooLarge), и бэк дополнительно перепроверяет размер при постановке генерации. - Не проксируй байты через
/v1/...— грузи строго формой наurl. - Форма одноразовая и недолгая. Истекла до заливки — запроси новую (шаг 1).
upload_idвалиден для генерации только после успешной заливки. На чужой/несуществующийupload_idсоздание генерации вернёт404 not_found; если файл так и не был залит (объекта нет в хранилище) или превышает лимит —422 validation_error.
Разрешение — отдельный лимит от размера в МБ
Помимо лимита в 15 МБ есть потолок по числу пикселей — ~100 мегапикселей
(ширина × высота). Снимки телефонных камер (48/64 МП) проходят с запасом; порог
защищает от «распаковочных бомб» — файлов, маленьких по байтам, но гигантских по
пикселям. Нюанс: разрешение проверяется не на создании генерации, а позже —
в воркере, когда бэк готовит фото для провайдера. Поэтому слишком большое по
пикселям фото даёт не синхронный 422, а генерацию в статусе failed с
error_code: "bad_input_image" — зарезервированные кредиты возвращаются
(см. коды отказа генерации).
Провайдер всё равно ужимает вход до ~1024px по длинной стороне, так что слать оригиналы в 100 МП — пустой трафик и лишний риск отказа. Ужимай фото на клиенте перед загрузкой (напр. до ~2048px по длинной стороне): аплоад быстрее, генерация надёжнее.
Несколько файлов¶
Одна presigned-форма — строго один объект: подписанная политика фиксирует один
key, а всё после первой части file хранилище игнорирует. Это ограничение самого
протокола S3 POST — два файла в одну форму не влезут.
Для N фото цикл повторяется целиком на каждый файл:
- N вызовов шага 1 — каждый возвращает свою пару
url+fieldsи свойupload_id. Запрашивать можно пачкой: лимит записи — token bucket на ~30 запросов burst с восполнением 1/сек; упёрся →429 rate_limited, ретрай с бэкоффом. - N заливок — параллельно можно и нужно: формы друг от друга не зависят, каждая
пишет в свой
key, коллизий нет. Каждая форма живёт своиexpires_inсекунд независимо от остальных. - Частичный провал — нормальная ситуация. Перезаливай только упавшие файлы; если форма успела истечь — перезапроси шаг 1 только для них. Успешные не трогай.
Связка с генерацией: в POST /v1/generations фото идут полем characters, списком
upload_id на каждого персонажа эффекта (до 4 персонажей и до 4 фото у каждого; сколько
реально берёт эффект - его characters в каталоге, как отправлять -
генерации). Каждое фото - своя загрузка: одно и то же upload_id дважды
в одном запуске не принимается.
Создавай генерацию только после 204 по ВСЕМ файлам
Правило «upload_id валиден после успешной заливки» действует на каждый файл
отдельно. Если любое из фото ещё не долилось или
его заливка упала, бэкенд на создании генерации не найдёт объект в хранилище и
вернёт 422 validation_error.
TTL upload_id (брошенные загрузки)¶
upload_id живёт до первого использования в генерации. Но если файл был залит,
а генерация так и не создана — запись остаётся «брошенной». Раз в час beat-задача
(sweep_orphaned_uploads) удаляет из БД и хранилища все загрузки старше 24 часов,
к которым не привязана ни одна генерация.
Не откладывай создание генерации
Если клиент загрузил фото, а пользователь ушёл и вернулся через сутки —
upload_id может уже не существовать. Реакция: 404 not_found при создании
генерации → попроси пользователя загрузить фото заново.
Пример на клиенте¶
var req = URLRequest(url: postURL)
req.httpMethod = "POST"
let boundary = UUID().uuidString
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
for (name, value) in fields { // все fields — как есть, без фильтрации
body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"\r\n\r\n\(value)\r\n".utf8))
}
body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"photo.jpg\"\r\n\r\n".utf8))
body.append(photoData) // файл — последней частью
body.append(Data("\r\n--\(boundary)--\r\n".utf8))
let (_, resp) = try await URLSession.shared.upload(for: req, from: body)
// (resp as! HTTPURLResponse).statusCode == 204
val form = MultipartBody.Builder().setType(MultipartBody.FORM)
fields.forEach { (name, value) -> form.addFormDataPart(name, value) } // как есть
form.addFormDataPart( // файл — последней частью
"file", "photo.jpg", photoFile.asRequestBody("image/jpeg".toMediaType())
)
val req = Request.Builder().url(url).post(form.build()).build()
client.newCall(req).execute().use { check(it.code == 204) }