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

Разрешение на прямую загрузку

Адаптер хранилища готов, и теперь можно разрешить одну конкретную загрузку. В «Архиве» Алиса отправляет небольшую JSON-заявку, API резервирует карточку и квоту, затем возвращает параметры прямого multipart POST в S3. Байты фотографии не проходят через HTTP-процесс приложения. Сервер управляет разрешением, а объектное хранилище принимает содержимое по строго ограниченной политике.

Главный результат урока — билет, связанный с серверной карточкой. Он содержит идентификатор, срок и поля формы, но не постоянные S3 credentials. Такой билет нельзя считать одноразовым: пока подпись действительна, запрос допускает повторное использование. Эта особенность объясняет, почему в предыдущем уроке мы потребовали versioning и почему подтверждение позже закрепит конкретный VersionId.

Резервирование квоты

Пусть квота Алисы составляет шестьдесят четыре мебибайта. Если проверить сумму перед вставкой без блокировки, два одновременных запроса увидят одинаковый остаток и оба пройдут. В итоге резерв превысит лимит, хотя каждый запрос по отдельности выглядел допустимым. Поэтому операцию «прочитать использованный объём и добавить новую карточку» выполняем в одной транзакции под пользовательской блокировкой.

Используем pg_advisory_xact_lock(hashtext(owner)): блокировка привязана к текущей транзакции и освобождается при её завершении. Возможная коллизия хеша может заставить разных пользователей подождать друг друга, но не позволит превысить квоту. Для крупного сервиса можно выделить отдельную строку счётчика квоты с блокировкой. В лаборатории явная транзакция позволяет увидеть границу операции без дополнительной модели баланса.

Полный файл reserve.mjs состояния 05:

import { randomUUID } from 'node:crypto';
import { transaction } from './db.mjs';
import { uploadPolicy } from './policy.mjs';
import { Failure } from './http.mjs';
import { ticket } from './store.mjs';
export async function reserve(owner,input) {
  const p=uploadPolicy(input); const id=randomUUID();
  const row=await transaction(async client=>{
    await client.query('SELECT pg_advisory_xact_lock(hashtext($1))',[owner]);
    const q=await client.query(`SELECT COALESCE(sum(expected_bytes),0)::bigint AS used
      FROM archive_files WHERE owner_id=$1 AND status<>'deleted'`,[owner]);
    if(Number(q.rows[0].used)+p.bytes>64*1024*1024) throw new Failure(409,'quota_exceeded');
    const inserted=await client.query(`INSERT INTO archive_files
      (id,owner_id,name,declared_type,expected_bytes,status,source_key,upload_expires)
      VALUES($1,$2,$3,$4,$5,'pending',$6,now()+interval '5 minutes') RETURNING *`,
      [id,owner,p.name,p.type,p.bytes,`incoming/${id}/${randomUUID()}`]);
    return inserted.rows[0];
  });
  const post=await ticket(row);
  return {id:row.id,status:row.status,expiresAt:row.upload_expires,post};
}

В сумму входят все записи, кроме deleted. Карточки pending, rejected и deleting ещё могут ссылаться на реальные объекты или действующее разрешение. Освободить резерв сразу после отказа обработки было бы преждевременно: байты остаются в бакете до очистки. Эта консервативная политика допускает временный отказ новой загрузки, зато не сообщает пользователю свободное место, которое физически ещё не освобождено.

UUID карточки и UUID части ключа генерирует сервер. Ключ не содержит имени сотрудника, имени файла или присланного клиентом пути. Знание UUID карточки не даёт возможности выбрать другой префикс. Мы также не используем шаблон ${filename} в ключе POST: приложение заранее знает полное имя объекта, который оно будет подтверждать. Это делает последующие проверки точными и проще связывает запись с объектом.

Подпись после фиксации карточки

Сначала транзакция фиксирует резерв, затем SDK формирует POST policy. Это две разные системы: PostgreSQL не может атомарно зафиксировать изменение вместе с выдачей S3-подписи. Если подпись сформировать не удалось, карточка остаётся pending и позже будет собрана очисткой. Такой остаток лучше, чем разрешение, существующее без зарегистрированной карточки и не учитываемое квотой.

В учебной функции POST policy устанавливает одинаковую нижнюю и верхнюю границу content-length-range, равную заявленным байтам. Это граница содержимого файла, а не всего JSON-запроса API. Поле Content-Type также должно точно совпасть с заявкой. Политика ограничивает форму передачи, но не доказывает, что внутри находится PNG. Подтверждение и decoder остаются обязательными даже при успешно проверенной подписи.

Ответ 201 на POST /v1/uploads означает создание карточки и билета. Он ещё не означает загрузку фотографии. Клиент строит FormData, добавляет каждое поле из post.fields без изменений, затем поле file, и отправляет запрос на post.url. Нельзя вручную установить общий Content-Type: multipart/form-data без boundary: браузер должен сформировать этот заголовок сам вместе с границами частей.

В интерфейсе полезно показывать три разных шага: разрешение получено, байты отправлены, файл подтверждён. Если соединение с S3 оборвалось, API по-прежнему знает лишь о pending. Если S3 ответил успешно, клиент вызывает confirm. Если confirm потерял ответ, клиент может повторить его по тому же UUID. Называть все три события одной «успешной загрузкой» означало бы скрыть реальные границы системы.

Повтор и срок действия

Upload-билет действует пять минут. Время выбирает сервер, а не посетитель. Короткий срок ограничивает период, когда разрешение может использоваться повторно, и помогает планировать удаление. Слишком короткий срок ухудшает поведение медленных сетей; слишком длинный увеличивает окно повторной записи и срок удержания квоты. Для реального продукта значение выбирают по размеру файлов и условиям пользователей, а не по удобству демонстрации.

Резервирование пока не имеет idempotency key. Если клиент повторит POST /v1/uploads после потери ответа, появится новая карточка и новый резерв. Это конкретное ограничение маршрута, которое нельзя скрывать за обещанием «безопасно повторять любые запросы». В интерфейсе следует сохранять полученный UUID и повторять дальнейшие шаги. Для промышленного клиента добавляют отдельный ключ идемпотентности, связанный с владельцем и исходной заявкой.

Важен и лимит числа билетов. Квота ограничивает суммарные байты, но пользователь способен создавать множество очень маленьких pending. Это нагружает базу и операцию очистки. В передаче сервиса нужно предусмотреть ограничение частоты резервирования и числа незавершённых карточек. Оно дополняет квоту объёма, потому что расход строк базы и стоимость S3-запросов не пропорциональны только размеру фотографии.

Потерянный билет нельзя восстановить, подписав произвольный source_key, присланный пользователем. Восстановление должно снова пройти через карточку и владельца, состояние и срок. В нашей лаборатории отдельное перевыпускание не реализовано; пользователь создаёт новую попытку, а старый резерв позже очищается. Это менее удобно, но сохраняет простую границу прав и не превращает дополнительный маршрут в обход всех проверок.

Ожидаемый результат пятого состояния — pending в списке Алисы и параметры POST, ограниченные её новой карточкой. Для Боба этот UUID не даёт доступа к списку Алисы. При превышении квоты ожидается 409 до выдачи разрешения. Ни успешный JSON, ни созданная подпись не подтверждают содержимое: следующий урок добавит серверное наблюдение конкретной версии объекта.

Предыдущий урок: Закрытое S3-совместимое хранилище · Следующий урок: Подтверждение загруженной версии

Оглавление серии · Исходники всех состояний.

Документация: AWS SDK: presigned POST, node-postgres: транзакции.