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

Закрытое S3-совместимое хранилище

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

В этой главе сервер ещё не выдаёт разрешений на загрузку. Сначала создаём адаптер и проверку конфигурации. Такой порядок помогает отделить проблему доступа к бакету от ошибки политики пользователя. Состояние lesson-04 содержит полный модуль хранилища, но вызывающий код пока использует только проверку версионирования при старте. Сам бакет и обращения к нему при подготовке материала не создавались и не выполнялись.

Закрытый бакет и роли

Бакет не должен иметь публичного чтения или режима статического сайта. У пользователя браузера нет постоянных S3 credentials. API получает учётные данные через окружение или роль среды исполнения и делегирует только отдельную операцию на короткий срок. Производственный выбор роли зависит от инфраструктуры; лабораторные пустые поля в .env.example не являются готовыми ключами доступа.

У адаптера задан один серверный бакет. Клиент не передаёт Bucket, endpoint или arbitrary key, которые затем попадут в SDK. Это предотвращает превращение API в подписывающий сервис для чужих объектов. Имя файла из карточки также не участвует в адресации. Каждая операция получает значения из созданной сервером записи, а не из формы, редактируемой посетителем.

Права роли ограничивают допустимые действия и префиксы. Для upload нужны запись в incoming/ и получение версии; для worker нужны чтение исходных версий и запись в derived/; для выдачи ссылки нужно чтение производной версии. Для очистки дополнительно нужны перечисление и удаление версий. В эксплуатации эти обязанности разумно разделить по ролям, чтобы обычный API не получал права массовой очистки всего бакета.

В учебном модуле один клиент показывает весь API взаимодействия. Это упрощает чтение, но не означает, что одна широкая роль обязательна. Граница модулей и граница credentials — разные решения. Можно использовать те же функции с отдельными клиентами процессов. Для реального выпуска в документации передачи фиксируют, какие действия доступны каждому процессу и какие префиксы ему разрешены.

Полный файл store.mjs состояния 04:

import { S3Client, HeadObjectCommand, GetObjectCommand, PutObjectCommand,
  ListObjectVersionsCommand, DeleteObjectsCommand, GetBucketVersioningCommand } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
export const bucket=process.env.S3_BUCKET;
export const s3=new S3Client({region:process.env.S3_REGION || 'us-east-1',
  endpoint:process.env.S3_ENDPOINT || undefined,
  forcePathStyle:process.env.S3_PATH_STYLE==='true'});
export async function requireVersioning() {
  const result=await s3.send(new GetBucketVersioningCommand({Bucket:bucket}));
  if(result.Status!=='Enabled') throw new Error('Bucket versioning must be Enabled');
}
export async function ticket(row) {
  const remaining=Math.floor((new Date(row.upload_expires).getTime()-Date.now())/1000);
  if(remaining<1) throw new Error('reservation_expired');
  return createPresignedPost(s3,{Bucket:bucket,Key:row.source_key,Expires:Math.min(300,remaining),
    Fields:{'Content-Type':row.declared_type},
    Conditions:[['content-length-range',row.expected_bytes,row.expected_bytes],
      ['eq','$Content-Type',row.declared_type]]});
}
export const head=key=>s3.send(new HeadObjectCommand({Bucket:bucket,Key:key}));
export async function bytes(key,version) {
  const result=await s3.send(new GetObjectCommand({Bucket:bucket,Key:key,VersionId:version}));
  return result.Body.transformToByteArray();
}
export async function put(key,body) {
  const result=await s3.send(new PutObjectCommand({Bucket:bucket,Key:key,Body:body,
    ContentType:'image/webp',CacheControl:'private, no-store'}));
  if(!result.VersionId || result.VersionId==='null') throw new Error('missing_version');
  return result.VersionId;
}
export async function download(row) {
  return getSignedUrl(s3,new GetObjectCommand({Bucket:bucket,Key:row.derived_key,
    VersionId:row.derived_version,ResponseContentType:'image/webp',
    ResponseContentDisposition:'attachment; filename="archive.webp"',
    ResponseCacheControl:'private, no-store'}),{expiresIn:60});
}
export async function versions(prefix) {
  const all=[]; let KeyMarker,VersionIdMarker;
  do {
    const page=await s3.send(new ListObjectVersionsCommand({Bucket:bucket,
      Prefix:prefix,KeyMarker,VersionIdMarker}));
    all.push(...(page.Versions||[]),...(page.DeleteMarkers||[]));
    if(!page.IsTruncated) break;
    KeyMarker=page.NextKeyMarker; VersionIdMarker=page.NextVersionIdMarker;
  } while(KeyMarker);
  return all;
}
export async function removeVersions(items) {
  for(let offset=0;offset<items.length;offset+=1000) {
    const result=await s3.send(new DeleteObjectsCommand({Bucket:bucket,
      Delete:{Objects:items.slice(offset,offset+1000).map(x=>({Key:x.Key,VersionId:x.VersionId})),Quiet:true}}));
    if(result.Errors?.length) throw new Error('partial_delete');
  }
}

Ключ и версия объекта

Объект выбирается парой «ключ плюс версия». Ключ остаётся постоянным именем, а каждая новая запись в versioned bucket получает собственный VersionId. Если клиент повторно использует ещё действующее upload-разрешение, он может записать новую версию по тому же ключу. Предыдущая версия при этом сохраняется. Поэтому только сохранение точного идентификатора версии позволяет обработать те байты, которые были приняты при подтверждении.

Функция requireVersioning() отказывается работать, если статус отличается от Enabled. Состояние Suspended также недостаточно: новые объекты могут получить null-версию. Проверка ответа HeadObject позднее ещё раз откажет при отсутствующем или строковом null идентификаторе. Это намеренный жёсткий контракт лаборатории, потому что запасной путь без версий вернул бы гонку замены содержимого.

Versioning увеличивает расходы хранения. Повторная загрузка по одному ключу оставляет несколько версий, и обычное удаление ключа может добавить delete marker вместо физического удаления старых байтов. Поэтому уже сейчас адаптер умеет перечислять версии и delete markers с пагинацией. Очистка должна видеть все страницы результата; первая тысяча объектов не представляет весь бакет.

Для AWS endpoint можно не задавать: SDK выбирает адрес по региону. Для другого провайдера используется S3_ENDPOINT, а forcePathStyle настраивается согласно его документации. Endpoint доступен и API, и браузеру, который получит подписанное разрешение. Внутреннее имя контейнера, недоступное браузеру, нельзя просто включить в upload URL и ожидать успешной отправки.

Протокол доступа браузера

Прямая загрузка требует CORS бакета. Разрешаем точный origin интерфейса и необходимые методы, а не любой origin без анализа продукта. CORS регулирует возможность браузерного скрипта читать ответ; он не устанавливает право на объект и не превращает бакет в закрытый. Пользователь вне браузера способен выполнить подписанный запрос без CORS, если владеет самим разрешением.

Заголовки, влияющие на подпись, должны совпасть с выданными условиями. Например, POST policy задаёт точное поле Content-Type. Клиент добавит все возвращённые поля формы без переименования, а файл — последним. Подписанное разрешение не редактируют при отправке и не заменяют его URL на «похожий» адрес. Подпись связывает метод, параметры и срок доступа, поэтому изменение существенных частей приводит к отказу.

Серверные credentials тоже имеют собственный срок и область прав. Временное разрешение не может пережить завершение роли, которой оно было подписано. Поэтому ответ API с числом секунд описывает верхнюю границу, а не обещание доступности при любых обстоятельствах. Отзыв ключей, изменение политики бакета или недоступность провайдера могут прекратить доступ раньше установленного времени.

Для производных изображений задаём image/webp и private, no-store. В запросе GET позже переопределяем Content-Disposition на безопасное фиксированное имя. Пользовательское имя не переносится в заголовок без отдельного кодирования. Предпочтение download вместо отображения оригинального файла снижает риск нежелательной интерпретации вложения браузером, но главным механизмом остаётся выдача контролируемого результата перекодирования.

Успешный старт четвёртого состояния ожидается только при доступном versioned bucket и достаточных правах проверки. При отсутствии Enabled процесс завершится с ошибкой конфигурации. Это не подтверждение выполненного нами обращения: в пакете сохранён исходный контракт. Перед своим запуском читатель должен сверить поддержку функций выбранного сервиса, особенно поведение POST policy и versionId.

Мы получили адаптер хранения, который умеет работать с точными версиями и не принимает адрес от посетителя. Следующая глава применит его к одной карточке: сначала резерв квоты и случайный ключ, затем ограниченное разрешение на загрузку. Полезно уже сейчас объяснить, почему закрытый бакет и проверка владельца нужны вместе: первый защищает прямой доступ, вторая определяет, какую операцию приложение вправе делегировать.

Предыдущий урок: Владелец файла и авторизация запросов · Следующий урок: Разрешение на прямую загрузку

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

Документация: S3 presigned URL, AWS SDK v3.