Перейти к содержанию

Загрузка фото

Фото грузится напрямую в хранилище (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-формате:

  • файл больше лимита → 400 EntityTooLarge;
  • поля формы изменены/пропущены или форма истекла → 403 AccessDenied / 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 фото цикл повторяется целиком на каждый файл:

  1. N вызовов шага 1 — каждый возвращает свою пару url + fields и свой upload_id. Запрашивать можно пачкой: лимит записи — token bucket на ~30 запросов burst с восполнением 1/сек; упёрся → 429 rate_limited, ретрай с бэкоффом.
  2. N заливок — параллельно можно и нужно: формы друг от друга не зависят, каждая пишет в свой key, коллизий нет. Каждая форма живёт свои expires_in секунд независимо от остальных.
  3. Частичный провал — нормальная ситуация. Перезаливай только упавшие файлы; если форма успела истечь — перезапроси шаг 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) }